gitoriaLog in with ident

notes

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit47dad68b47dad68bnotes: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gates 50/0 + 18/0mre47dad68b/notes.hl

8.8 KB

  1. // notes.hl — THE NOTES (ticket notes#1). One hl:mpackdb table, storage/mpackdb/notes.db:
  2. // pk @id index owner { owner (users @id), text (Markdown), created, edited, gone, local } times = ms
  3. // gone = ms a note was deleted (0 = alive; the row stays as a TOMBSTONE so other devices learn of the delete);
  4. // local = the id the creating device gave the note before it had a server id (makes a retried push idempotent)
  5. // The SUBJECT of a note is not stored: it is the first non-empty line of the text, without leading `#`
  6. // (CONCEPT.md "Decisions"). The sidebar lists a user's notes by `edited`, last one first.
  7. // Every function takes the owner's user id and only ever reaches that user's notes.
  8. import { MPackDB } from 'hl:mpackdb'
  9. import { now } from 'hl:time'
  10. import { storageDir, firstOf, countOfList } from './users.hl'
  11. static notesTable = new MPackDB(file = storageDir + '/notes.db', primaryKey = '@id', indexes = ['owner'])
  12. static maxText = 200000
  13. static maxSubject = 80
  14. static untitled = 'Untitled note'
  15. // the first non-empty line, leading `#`s and spaces dropped, cut at maxSubject characters
  16. static subjectOf = (text) => {
  17. let lines = text.split('
  18. ')
  19. let i = 0
  20. while (i < lines.length) {
  21. let line = lines[i].trim()
  22. let at = 0
  23. while (at < line.length && line[at] == '#') { at = at + 1 }
  24. line = line.slice(at).trim()
  25. if (line != '') { return line.length > maxSubject ? line.slice(0, maxSubject) : line }
  26. i = i + 1
  27. }
  28. return untitled
  29. }
  30. static rowOf = (n) => {
  31. return { id = n.id subject = subjectOf(n.text) href = '/note/' + n.id }
  32. }
  33. static isAlive = (n) => { return n != null && (n.gone == null || n.gone == 0) }
  34. static aliveOf = (list) => {
  35. let out = []
  36. let i = 0
  37. let n = countOfList(list)
  38. while (i < n) { if (isAlive(list[i])) { out.push(list[i]) } i = i + 1 }
  39. return out
  40. }
  41. // the owner's notes as sidebar rows, last edited first (a hand-written sort: hybriel#1)
  42. static noteRows = (userId) => {
  43. let found = aliveOf(notesTable.find('owner', userId))
  44. let n = countOfList(found)
  45. let used = []
  46. let i = 0
  47. while (i < n) { used.push(false) i = i + 1 }
  48. let rows = []
  49. let round = 0
  50. while (round < n) {
  51. let best = -1
  52. let j = 0
  53. while (j < n) {
  54. if (!used[j] && (best < 0 || found[j].edited > found[best].edited)) { best = j }
  55. j = j + 1
  56. }
  57. used[best] = true
  58. rows.push(rowOf(found[best]))
  59. round = round + 1
  60. }
  61. return rows
  62. }
  63. // the text of one note of this owner, or null
  64. static noteTextOf = (userId, id) => {
  65. let n = noteOf(userId, id)
  66. return n == null ? null : n.text
  67. }
  68. // one note of this owner, or null
  69. static noteOf = (userId, id) => {
  70. if (id == null || hlTypeName(id) != 'String' || id == '') { return null }
  71. let n = notesTable.fetch(id)
  72. if (n == null || n.owner != userId || !isAlive(n)) { return null }
  73. return n
  74. }
  75. static textError = (text) => {
  76. if (text == null || hlTypeName(text) != 'String') { return 'the note must be text' }
  77. if (text.length > maxText) { return 'the note is too long (at most ' + maxText + ' characters)' }
  78. return null
  79. }
  80. static createNote = (userId) => {
  81. let t = now()
  82. let id = notesTable.put({ owner = userId text = '' created = t edited = t gone = 0 local = '' })
  83. if (id == null) { return { error = 'could not store the note: ' + notesTable.lastError() } }
  84. return { id = id }
  85. }
  86. // only a CHANGED text moves the note to the top (an editor that re-sends the same text is not an edit)
  87. static saveNote = (userId, id, text) => {
  88. let bad = textError(text)
  89. if (bad != null) { return { error = bad } }
  90. let n = noteOf(userId, id)
  91. if (n == null) { return { error = 'no such note' } }
  92. if (n.text != text) {
  93. notesTable.update(id, { id = id owner = userId text = text created = n.created edited = now() gone = 0 local = n.local == null ? '' : n.local })
  94. let after = notesTable.fetch(id)
  95. if (after == null || after.text != text) { return { error = 'could not store the note: ' + notesTable.lastError() } }
  96. }
  97. return { rows = noteRows(userId) }
  98. }
  99. static deleteNote = (userId, id) => {
  100. let n = noteOf(userId, id)
  101. if (n == null) { return { error = 'no such note' } }
  102. notesTable.update(id, { id = id owner = userId text = '' created = n.created edited = n.edited gone = now() local = n.local == null ? '' : n.local })
  103. return { ok = true }
  104. }
  105. // ---- SYNC (ticket notes#2): the browser keeps its own copy (IndexedDB) and pushes/pulls through /api/notes ----
  106. // A note's VERSION is its `edited` (ms). The browser remembers the `edited` it last saw of a note (`base`).
  107. // what the browser needs to compare: id, edited, gone (no text) of every note of the owner, tombstones included
  108. static metaRows = (userId) => {
  109. let found = notesTable.find('owner', userId)
  110. let out = []
  111. let i = 0
  112. let n = countOfList(found)
  113. while (i < n) {
  114. out.push({ id = found[i].id edited = found[i].edited gone = isAlive(found[i]) ? 0 : found[i].gone })
  115. i = i + 1
  116. }
  117. return out
  118. }
  119. // one note with its text, or null
  120. static noteView = (userId, id) => {
  121. let n = noteOf(userId, id)
  122. return n == null ? null : { id = n.id text = n.text edited = n.edited created = n.created }
  123. }
  124. static copyMark = '
  125. *(kept as a copy: this note was changed on two devices at once)*'
  126. // the version stamp of an edit made at `at` (the browser's clock): never in the future, always after `prev` —
  127. // so the sidebar order is the order the edits were really made in, and a version stamp is never reused
  128. static stampOf = (at, prev) => {
  129. let t = now()
  130. let cand = at > 0 && at < t ? at : t
  131. return cand > prev ? cand : prev + 1
  132. }
  133. static newNote = (userId, text, local, edited) => {
  134. let t = now()
  135. let id = notesTable.put({ owner = userId text = text created = t edited = stampOf(edited, 0) gone = 0 local = local })
  136. return id
  137. }
  138. // a note pushed by a browser: { id (server id or ''), local (the browser's own id of a never-synced note), text,
  139. // base (the `edited` the browser last saw, 0 = never), at (when the browser last edited it) }
  140. // → { id, edited, text, copy? }: `text` is what the note is NOW (the browser takes it); `copy` = { id, edited, text }
  141. // is the version that lost a conflict, kept as a note of its own — nothing is ever lost.
  142. static pushNote = (userId, id, local, text, base, at) => {
  143. let bad = textError(text)
  144. if (bad != null) { return { error = bad } }
  145. let n = null
  146. if (id != '') { n = noteOf(userId, id) }
  147. if (n == null && local != '') {
  148. // a retry after a lost answer: the note the same browser already made
  149. let mine = notesTable.find('owner', userId)
  150. let i = 0
  151. let c = countOfList(mine)
  152. while (i < c && n == null) {
  153. if (isAlive(mine[i]) && mine[i].local == local) { n = mine[i] }
  154. i = i + 1
  155. }
  156. }
  157. if (n == null) {
  158. // a new note (or one deleted elsewhere while this browser edited it: the edit becomes a note again)
  159. let nid = newNote(userId, text, local, at > 0 ? at : now())
  160. if (nid == null) { return { error = 'could not store the note: ' + notesTable.lastError() } }
  161. let made = notesTable.fetch(nid)
  162. return { id = nid edited = made.edited text = text }
  163. }
  164. if (n.text == text) { return { id = n.id edited = n.edited text = n.text } }
  165. if (n.edited == base) {
  166. let ed = stampOf(at, n.edited)
  167. notesTable.update(n.id, { id = n.id owner = userId text = text created = n.created edited = ed gone = 0 local = n.local == null ? '' : n.local })
  168. let after = notesTable.fetch(n.id)
  169. if (after == null || after.text != text) { return { error = 'could not store the note: ' + notesTable.lastError() } }
  170. return { id = n.id edited = after.edited text = text }
  171. }
  172. // CONFLICT: changed here AND there. The later edit wins (last write wins); the other version becomes a copy.
  173. let serverText = n.text
  174. let serverEdited = n.edited
  175. if (at > serverEdited) {
  176. let cid = newNote(userId, serverText + copyMark, '', serverEdited)
  177. let ed = stampOf(at, n.edited)
  178. notesTable.update(n.id, { id = n.id owner = userId text = text created = n.created edited = ed gone = 0 local = n.local == null ? '' : n.local })
  179. let after = notesTable.fetch(n.id)
  180. let cp = cid == null ? null : notesTable.fetch(cid)
  181. return { id = n.id edited = after.edited text = text copy = cp == null ? null : { id = cp.id edited = cp.edited text = cp.text } }
  182. }
  183. let cid = newNote(userId, text + copyMark, '', at)
  184. let cp = cid == null ? null : notesTable.fetch(cid)
  185. return { id = n.id edited = n.edited text = n.text copy = cp == null ? null : { id = cp.id edited = cp.edited text = cp.text } }
  186. }
  187. // a delete pushed by a browser. Deleted elsewhere already = fine. Changed elsewhere since the browser's `base`
  188. // = NOT deleted (the changed note is kept; the browser takes it back).
  189. static removeNote = (userId, id, base) => {
  190. let raw = id == null || hlTypeName(id) != 'String' || id == '' ? null : notesTable.fetch(id)
  191. if (raw == null || raw.owner != userId) { return { ok = true } }
  192. if (!isAlive(raw)) { return { ok = true } }
  193. if (raw.edited != base) { return { kept = true id = raw.id edited = raw.edited text = raw.text } }
  194. deleteNote(userId, id)
  195. return { ok = true }
  196. }

Branches

Latest commits

  • 47dad68bnotes: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gates 50/0 + 18/0mre
  • a4a2b2aeantcolony#40: tracker missions moved too — references to them in missions/reports/LOG.md updatedmre
  • 3dea0ef2notes: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 50/0mre
  • e27c7d71notes: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy; audit: nothing to fix; gate 50/0)mre
  • 124613b6antcolony#40: mission references point to the moved missionsmre
  • 1ca2f34dantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 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