notes
All repositories: gitoria
8.8 KB
// notes.hl — THE NOTES (ticket notes#1). One hl:mpackdb table, storage/mpackdb/notes.db:// pk @id index owner { owner (users @id), text (Markdown), created, edited, gone, local } times = ms// gone = ms a note was deleted (0 = alive; the row stays as a TOMBSTONE so other devices learn of the delete);// local = the id the creating device gave the note before it had a server id (makes a retried push idempotent)// The SUBJECT of a note is not stored: it is the first non-empty line of the text, without leading `#`// (CONCEPT.md "Decisions"). The sidebar lists a user's notes by `edited`, last one first.// Every function takes the owner's user id and only ever reaches that user's notes.import { MPackDB } from 'hl:mpackdb'import { now } from 'hl:time'import { storageDir, firstOf, countOfList } from './users.hl'static notesTable = new MPackDB(file = storageDir + '/notes.db', primaryKey = '@id', indexes = ['owner'])static maxText = 200000static maxSubject = 80static untitled = 'Untitled note'// the first non-empty line, leading `#`s and spaces dropped, cut at maxSubject charactersstatic subjectOf = (text) => {let lines = text.split('')let i = 0while (i < lines.length) {let line = lines[i].trim()let at = 0while (at < line.length && line[at] == '#') { at = at + 1 }line = line.slice(at).trim()if (line != '') { return line.length > maxSubject ? line.slice(0, maxSubject) : line }i = i + 1}return untitled}static rowOf = (n) => {return { id = n.id subject = subjectOf(n.text) href = '/note/' + n.id }}static isAlive = (n) => { return n != null && (n.gone == null || n.gone == 0) }static aliveOf = (list) => {let out = []let i = 0let n = countOfList(list)while (i < n) { if (isAlive(list[i])) { out.push(list[i]) } i = i + 1 }return out}// the owner's notes as sidebar rows, last edited first (a hand-written sort: hybriel#1)static noteRows = (userId) => {let found = aliveOf(notesTable.find('owner', userId))let n = countOfList(found)let used = []let i = 0while (i < n) { used.push(false) i = i + 1 }let rows = []let round = 0while (round < n) {let best = -1let j = 0while (j < n) {if (!used[j] && (best < 0 || found[j].edited > found[best].edited)) { best = j }j = j + 1}used[best] = truerows.push(rowOf(found[best]))round = round + 1}return rows}// the text of one note of this owner, or nullstatic noteTextOf = (userId, id) => {let n = noteOf(userId, id)return n == null ? null : n.text}// one note of this owner, or nullstatic noteOf = (userId, id) => {if (id == null || hlTypeName(id) != 'String' || id == '') { return null }let n = notesTable.fetch(id)if (n == null || n.owner != userId || !isAlive(n)) { return null }return n}static textError = (text) => {if (text == null || hlTypeName(text) != 'String') { return 'the note must be text' }if (text.length > maxText) { return 'the note is too long (at most ' + maxText + ' characters)' }return null}static createNote = (userId) => {let t = now()let id = notesTable.put({ owner = userId text = '' created = t edited = t gone = 0 local = '' })if (id == null) { return { error = 'could not store the note: ' + notesTable.lastError() } }return { id = id }}// only a CHANGED text moves the note to the top (an editor that re-sends the same text is not an edit)static saveNote = (userId, id, text) => {let bad = textError(text)if (bad != null) { return { error = bad } }let n = noteOf(userId, id)if (n == null) { return { error = 'no such note' } }if (n.text != text) {notesTable.update(id, { id = id owner = userId text = text created = n.created edited = now() gone = 0 local = n.local == null ? '' : n.local })let after = notesTable.fetch(id)if (after == null || after.text != text) { return { error = 'could not store the note: ' + notesTable.lastError() } }}return { rows = noteRows(userId) }}static deleteNote = (userId, id) => {let n = noteOf(userId, id)if (n == null) { return { error = 'no such note' } }notesTable.update(id, { id = id owner = userId text = '' created = n.created edited = n.edited gone = now() local = n.local == null ? '' : n.local })return { ok = true }}// ---- SYNC (ticket notes#2): the browser keeps its own copy (IndexedDB) and pushes/pulls through /api/notes ----// A note's VERSION is its `edited` (ms). The browser remembers the `edited` it last saw of a note (`base`).// what the browser needs to compare: id, edited, gone (no text) of every note of the owner, tombstones includedstatic metaRows = (userId) => {let found = notesTable.find('owner', userId)let out = []let i = 0let n = countOfList(found)while (i < n) {out.push({ id = found[i].id edited = found[i].edited gone = isAlive(found[i]) ? 0 : found[i].gone })i = i + 1}return out}// one note with its text, or nullstatic noteView = (userId, id) => {let n = noteOf(userId, id)return n == null ? null : { id = n.id text = n.text edited = n.edited created = n.created }}static copyMark = '*(kept as a copy: this note was changed on two devices at once)*'// the version stamp of an edit made at `at` (the browser's clock): never in the future, always after `prev` —// so the sidebar order is the order the edits were really made in, and a version stamp is never reusedstatic stampOf = (at, prev) => {let t = now()let cand = at > 0 && at < t ? at : treturn cand > prev ? cand : prev + 1}static newNote = (userId, text, local, edited) => {let t = now()let id = notesTable.put({ owner = userId text = text created = t edited = stampOf(edited, 0) gone = 0 local = local })return id}// a note pushed by a browser: { id (server id or ''), local (the browser's own id of a never-synced note), text,// base (the `edited` the browser last saw, 0 = never), at (when the browser last edited it) }// → { id, edited, text, copy? }: `text` is what the note is NOW (the browser takes it); `copy` = { id, edited, text }// is the version that lost a conflict, kept as a note of its own — nothing is ever lost.static pushNote = (userId, id, local, text, base, at) => {let bad = textError(text)if (bad != null) { return { error = bad } }let n = nullif (id != '') { n = noteOf(userId, id) }if (n == null && local != '') {// a retry after a lost answer: the note the same browser already madelet mine = notesTable.find('owner', userId)let i = 0let c = countOfList(mine)while (i < c && n == null) {if (isAlive(mine[i]) && mine[i].local == local) { n = mine[i] }i = i + 1}}if (n == null) {// a new note (or one deleted elsewhere while this browser edited it: the edit becomes a note again)let nid = newNote(userId, text, local, at > 0 ? at : now())if (nid == null) { return { error = 'could not store the note: ' + notesTable.lastError() } }let made = notesTable.fetch(nid)return { id = nid edited = made.edited text = text }}if (n.text == text) { return { id = n.id edited = n.edited text = n.text } }if (n.edited == base) {let ed = stampOf(at, n.edited)notesTable.update(n.id, { id = n.id owner = userId text = text created = n.created edited = ed gone = 0 local = n.local == null ? '' : n.local })let after = notesTable.fetch(n.id)if (after == null || after.text != text) { return { error = 'could not store the note: ' + notesTable.lastError() } }return { id = n.id edited = after.edited text = text }}// CONFLICT: changed here AND there. The later edit wins (last write wins); the other version becomes a copy.let serverText = n.textlet serverEdited = n.editedif (at > serverEdited) {let cid = newNote(userId, serverText + copyMark, '', serverEdited)let ed = stampOf(at, n.edited)notesTable.update(n.id, { id = n.id owner = userId text = text created = n.created edited = ed gone = 0 local = n.local == null ? '' : n.local })let after = notesTable.fetch(n.id)let cp = cid == null ? null : notesTable.fetch(cid)return { id = n.id edited = after.edited text = text copy = cp == null ? null : { id = cp.id edited = cp.edited text = cp.text } }}let cid = newNote(userId, text + copyMark, '', at)let cp = cid == null ? null : notesTable.fetch(cid)return { id = n.id edited = n.edited text = n.text copy = cp == null ? null : { id = cp.id edited = cp.edited text = cp.text } }}// a delete pushed by a browser. Deleted elsewhere already = fine. Changed elsewhere since the browser's `base`// = NOT deleted (the changed note is kept; the browser takes it back).static removeNote = (userId, id, base) => {let raw = id == null || hlTypeName(id) != 'String' || id == '' ? null : notesTable.fetch(id)if (raw == null || raw.owner != userId) { return { ok = true } }if (!isAlive(raw)) { return { ok = true } }if (raw.edited != base) { return { kept = true id = raw.id edited = raw.edited text = raw.text } }deleteNote(userId, id)return { ok = true }}
Branches
- mainmain branch
Latest commits
- 2bebebdanotes: Hybriel master ff51cf46 (re-vendor round)mre
- 3eff126dnotes#3: installable app (manifest + own icon/favicon; notes' own sw.js kept)mre
- 9883c540deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- eee693b8deploy.sh: never send .git or .gitignore to Byrodinmre
- c8904061State of 2026-09-27, before the move to gitoriamre