gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit752fbb7f752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre752fbb7f/mdview.hl

6.7 KB

  1. // mdview.hl — THE MARKDOWN READ VIEW FOR LLM READERS (ticket #6, mission 014; CONCEPT.md "Data
  2. // formats": a Markdown read view for LLM readers). The ticket GET endpoints answer
  3. // `Accept: text/markdown` with a compact Markdown document instead of JSON (project.hl
  4. // `wantsMarkdown`); JSON stays the default and is unchanged. Statics only.
  5. //
  6. // one ticket → # <project> #<n>: <subject>
  7. // state · opened (by) · updated · formerly · URL
  8. // the summary as written (it IS Markdown)
  9. // ## History — one `### <seq> · <when> · <author> <what>` per event, its text below
  10. // a list → # Tickets (<filters>): <count>
  11. // one line per ticket: `- <project> #<n> [<state>] <subject> · updated <when> · <url>`
  12. // Texts are embedded as written (they are Markdown already), so a text's own headings sit
  13. // inside the document — readers should take the `### <seq> ·` lines as the event boundaries.
  14. import { Response } from 'hl:http1'
  15. import { publicUrl } from './users.hl'
  16. static NL = "
  17. "
  18. // does the request ask for Markdown? `Accept` names text/markdown with q > 0 and prefers it to
  19. // application/json (a higher q, or the same q and listed first). No Accept / */* → JSON.
  20. static qOf = (part) => {
  21. let q = 1
  22. for (p of part.split(';')) {
  23. let kv = p.trim()
  24. if (kv.startsWith('q=')) { q = toNumber(kv.slice(2)) }
  25. }
  26. return q == null ? 0 : q
  27. }
  28. static wantsMarkdown = (req) => {
  29. let h = req.headers['accept']
  30. if (h == null || hlTypeName(h) != 'String') { return false }
  31. let md = -1
  32. let js = -1
  33. let mdAt = -1
  34. let jsAt = -1
  35. let i = 0
  36. for (part of h.toLowerCase().split(',')) {
  37. let type = part.split(';')[0].trim()
  38. if (type == 'text/markdown' && mdAt < 0) {
  39. md = qOf(part)
  40. mdAt = i
  41. }
  42. if (type == 'application/json' && jsAt < 0) {
  43. js = qOf(part)
  44. jsAt = i
  45. }
  46. i = i + 1
  47. }
  48. if (md <= 0) { return false }
  49. if (js < 0) { return true }
  50. return md > js || (md == js && mdAt < jsAt)
  51. }
  52. static markdownReply = (text) => {
  53. return new Response(text, { status = 200 headers = { 'Content-Type' = 'text/markdown; charset=utf-8' 'Vary' = 'Accept' } })
  54. }
  55. // one line: line breaks and runs of spaces become one space
  56. static oneLine = (s) => {
  57. if (s == null) { return '' }
  58. let parts = []
  59. for (w of ('' + s).replaceAll(NL, ' ').split(' ')) { if (w != '') { parts.push(w) } }
  60. return parts.join(' ')
  61. }
  62. // `> ` in front of every line (a previous summary inside an edit event)
  63. static quoted = (s) => {
  64. let out = []
  65. for (l of ('' + s).split(NL)) { out.push(l == '' ? '>' : '> ' + l) }
  66. return out.join(NL)
  67. }
  68. static ticketLine = (row) => {
  69. return '- ' + row.project + ' ' + row.ref + ' [' + row.state + '] ' + oneLine(row.subject) + ' · updated ' + row.updated + ' · ' + publicUrl + row.href + relationTail(row)
  70. }
  71. // ---- relations (ticket #4, mission 017) --------------------------------------------------------
  72. // one ticket's document gets "Parent:", "Children (k of n done):", "Blocked by:", "Blocks:"
  73. // blocks under its URL; a list line gets ` · parent … · children n, k done · blocked by … · blocks …`
  74. static refName = (r) => { return r.project + ' ' + r.ref }
  75. static refLine = (r) => { return '- ' + refName(r) + ' [' + r.state + '] ' + oneLine(r.subject) + ' · ' + publicUrl + r.href }
  76. static confirmedOf = (refs) => {
  77. let n = 0
  78. for (r of refs) { if (r.state == 'done') { n = n + 1 } }
  79. return n
  80. }
  81. static relationTail = (row) => {
  82. let parts = []
  83. if (row.parent != null) { parts.push('parent ' + refName(row.parent)) }
  84. let total = row.children.length
  85. if (total > 0) { parts.push('children ' + total + (row.allChildrenConfirmed ? ', all done' : ', ' + confirmedOf(row.children) + ' done')) }
  86. if (row.blockedBy.length > 0) {
  87. let bs = []
  88. for (r of row.blockedBy) { bs.push(refName(r) + ' [' + r.state + ']') }
  89. parts.push('blocked by ' + bs.join(', '))
  90. }
  91. if (row.blocks.length > 0) {
  92. let bs = []
  93. for (r of row.blocks) { bs.push(refName(r)) }
  94. parts.push('blocks ' + bs.join(', '))
  95. }
  96. return parts.length == 0 ? '' : ' · ' + parts.join(' · ')
  97. }
  98. static relationLines = (t) => {
  99. let lines = []
  100. if (t.parent != null) {
  101. lines.push('')
  102. lines.push('Parent: ' + refName(t.parent) + ' [' + t.parent.state + '] ' + oneLine(t.parent.subject) + ' · ' + publicUrl + t.parent.href)
  103. }
  104. let total = t.children.length
  105. if (total > 0) {
  106. lines.push('')
  107. lines.push('Children (' + (t.allChildrenConfirmed ? 'all ' + total + ' done' : confirmedOf(t.children) + ' of ' + total + ' done') + '):')
  108. for (r of t.children) { lines.push(refLine(r)) }
  109. }
  110. if (t.blockedBy.length > 0) {
  111. lines.push('')
  112. lines.push('Blocked by:')
  113. for (r of t.blockedBy) { lines.push(refLine(r)) }
  114. }
  115. if (t.blocks.length > 0) {
  116. lines.push('')
  117. lines.push('Blocks:')
  118. for (r of t.blocks) { lines.push(refLine(r)) }
  119. }
  120. return lines
  121. }
  122. // a list: { tickets } (rows, as the JSON answer) + a label of its filters ('' = all)
  123. static listDocument = (rows, filters) => {
  124. let lines = []
  125. lines.push('# Tickets' + (filters != '' ? ' (' + filters + ')' : '') + ': ' + rows.length)
  126. lines.push('')
  127. for (r of rows) { lines.push(ticketLine(r)) }
  128. return lines.join(NL) + NL
  129. }
  130. static eventHeadline = (e) => {
  131. let what = e.label
  132. if (e.kind == 'state') { what = 'changed the state: ' + (e.from != '' ? e.from : '?') + ' → ' + e.to }
  133. if (e.kind == 'edit') {
  134. let parts = []
  135. if (e.subjectChanged) { parts.push('subject') }
  136. if (e.summaryChanged) { parts.push('summary') }
  137. what = 'edited the ' + parts.join(' and ')
  138. }
  139. return '### ' + e.seq + ' · ' + e.when + ' · ' + oneLine(e.author) + ' ' + what
  140. }
  141. // one ticket: { ticket (row), events (rows, oldest first) } as the JSON answer
  142. static ticketDocument = (data) => {
  143. let t = data.ticket
  144. let opener = ''
  145. for (e of data.events) { if (e.kind == 'created' && opener == '') { opener = oneLine(e.author) } }
  146. let lines = []
  147. lines.push('# ' + t.project + ' ' + t.ref + ': ' + oneLine(t.subject))
  148. lines.push('')
  149. let meta = 'state: ' + t.state + ' · opened ' + t.created + (opener != '' ? ' by ' + opener : '') + ' · updated ' + t.updated
  150. if (t.hasOld) { meta = meta + ' · formerly ' + t.oldRef }
  151. lines.push(meta)
  152. lines.push(publicUrl + t.href)
  153. for (l of relationLines(t)) { lines.push(l) }
  154. lines.push('')
  155. lines.push(t.summary != '' ? t.summary : '(no summary)')
  156. lines.push('')
  157. lines.push('## History')
  158. for (e of data.events) {
  159. lines.push('')
  160. lines.push(eventHeadline(e))
  161. if (e.kind == 'edit') {
  162. if (e.subjectChanged) {
  163. lines.push('')
  164. lines.push('Subject before: ' + oneLine(e.oldSubject))
  165. }
  166. if (e.summaryChanged) {
  167. lines.push('')
  168. lines.push('Summary before:')
  169. lines.push('')
  170. lines.push(e.oldSummary != '' ? quoted(e.oldSummary) : '> (empty)')
  171. }
  172. }
  173. if (e.hasText) {
  174. lines.push('')
  175. lines.push(e.text)
  176. }
  177. }
  178. return lines.join(NL) + NL
  179. }

Branches

Latest commits

  • 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre