@kernhq/module-quire 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/dist/contract/models.d.ts +394 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +284 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts +14 -0
  6. package/dist/contract/permissions.d.ts.map +1 -1
  7. package/dist/contract/permissions.js +114 -0
  8. package/dist/contract/permissions.js.map +1 -1
  9. package/dist/contract/properties.d.ts +7 -7
  10. package/dist/contract/router.d.ts +1124 -96
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +343 -1
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +983 -1168
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +308 -0
  17. package/dist/server/_impl.js.map +1 -1
  18. package/dist/server/export/html.d.ts +67 -0
  19. package/dist/server/export/html.d.ts.map +1 -0
  20. package/dist/server/export/html.js +206 -0
  21. package/dist/server/export/html.js.map +1 -0
  22. package/dist/server/export/markdown.d.ts +51 -0
  23. package/dist/server/export/markdown.d.ts.map +1 -0
  24. package/dist/server/export/markdown.js +351 -0
  25. package/dist/server/export/markdown.js.map +1 -0
  26. package/dist/server/export/pdf.d.ts +20 -0
  27. package/dist/server/export/pdf.d.ts.map +1 -0
  28. package/dist/server/export/pdf.js +91 -0
  29. package/dist/server/export/pdf.js.map +1 -0
  30. package/dist/server/export/zip.d.ts +31 -0
  31. package/dist/server/export/zip.d.ts.map +1 -0
  32. package/dist/server/export/zip.js +158 -0
  33. package/dist/server/export/zip.js.map +1 -0
  34. package/dist/server/import/csv.d.ts +77 -0
  35. package/dist/server/import/csv.d.ts.map +1 -0
  36. package/dist/server/import/csv.js +263 -0
  37. package/dist/server/import/csv.js.map +1 -0
  38. package/dist/server/import/html.d.ts +52 -0
  39. package/dist/server/import/html.d.ts.map +1 -0
  40. package/dist/server/import/html.js +472 -0
  41. package/dist/server/import/html.js.map +1 -0
  42. package/dist/server/import/markdown.d.ts +63 -0
  43. package/dist/server/import/markdown.d.ts.map +1 -0
  44. package/dist/server/import/markdown.js +692 -0
  45. package/dist/server/import/markdown.js.map +1 -0
  46. package/dist/server/import/plan.d.ts +70 -0
  47. package/dist/server/import/plan.d.ts.map +1 -0
  48. package/dist/server/import/plan.js +761 -0
  49. package/dist/server/import/plan.js.map +1 -0
  50. package/dist/server/import/ydoc.d.ts +35 -0
  51. package/dist/server/import/ydoc.d.ts.map +1 -0
  52. package/dist/server/import/ydoc.js +91 -0
  53. package/dist/server/import/ydoc.js.map +1 -0
  54. package/dist/server/import/zip.d.ts +63 -0
  55. package/dist/server/import/zip.d.ts.map +1 -0
  56. package/dist/server/import/zip.js +308 -0
  57. package/dist/server/import/zip.js.map +1 -0
  58. package/dist/server/index.d.ts.map +1 -1
  59. package/dist/server/index.js +3 -1
  60. package/dist/server/index.js.map +1 -1
  61. package/dist/server/render.d.ts +158 -0
  62. package/dist/server/render.d.ts.map +1 -1
  63. package/dist/server/render.js +240 -0
  64. package/dist/server/render.js.map +1 -1
  65. package/dist/server/schema.d.ts +704 -1
  66. package/dist/server/schema.d.ts.map +1 -1
  67. package/dist/server/schema.js +254 -1
  68. package/dist/server/schema.js.map +1 -1
  69. package/dist/server/services/databases.d.ts +10 -10
  70. package/dist/server/services/export.d.ts +176 -0
  71. package/dist/server/services/export.d.ts.map +1 -0
  72. package/dist/server/services/export.js +822 -0
  73. package/dist/server/services/export.js.map +1 -0
  74. package/dist/server/services/import.d.ts +109 -0
  75. package/dist/server/services/import.d.ts.map +1 -0
  76. package/dist/server/services/import.js +570 -0
  77. package/dist/server/services/import.js.map +1 -0
  78. package/dist/server/services/index.d.ts +32 -1
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +87 -4
  81. package/dist/server/services/index.js.map +1 -1
  82. package/dist/server/services/macros.d.ts +83 -0
  83. package/dist/server/services/macros.d.ts.map +1 -0
  84. package/dist/server/services/macros.js +488 -0
  85. package/dist/server/services/macros.js.map +1 -0
  86. package/dist/server/services/publications.d.ts +2 -1
  87. package/dist/server/services/publications.d.ts.map +1 -1
  88. package/dist/server/services/publications.js +43 -4
  89. package/dist/server/services/publications.js.map +1 -1
  90. package/dist/server/services/templates.d.ts +135 -0
  91. package/dist/server/services/templates.d.ts.map +1 -0
  92. package/dist/server/services/templates.js +897 -0
  93. package/dist/server/services/templates.js.map +1 -0
  94. package/dist/server/services/versions.d.ts +13 -1
  95. package/dist/server/services/versions.d.ts.map +1 -1
  96. package/dist/server/services/versions.js +3 -1
  97. package/dist/server/services/versions.js.map +1 -1
  98. package/migrations/0010_transfers.sql +154 -0
  99. package/migrations/0011_templates.sql +157 -0
  100. package/migrations/meta/_journal.json +14 -0
  101. package/package.json +5 -5
  102. package/src/client/components/ExportDialog.svelte +685 -0
  103. package/src/client/components/ImportDialog.svelte +702 -0
  104. package/src/client/components/ImportReport.svelte +310 -0
  105. package/src/client/components/NewSpaceDialog.svelte +77 -8
  106. package/src/client/components/PageEditor.svelte +80 -0
  107. package/src/client/components/PagePicker.svelte +264 -0
  108. package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
  109. package/src/client/components/SidebarSpaces.svelte +112 -1
  110. package/src/client/components/TemplatePicker.svelte +437 -0
  111. package/src/client/i18n.ts +941 -0
  112. package/src/client/index.ts +51 -0
  113. package/src/client/mock.ts +592 -0
  114. package/src/client/module.ts +36 -0
  115. package/src/client/pages/PageView.svelte +86 -0
  116. package/src/client/pages/SpacePage.svelte +20 -4
  117. package/src/client/pages/TransfersPage.svelte +570 -0
  118. package/src/client/permissions.ts +11 -0
  119. package/src/client/query.ts +40 -0
  120. package/src/client/transfers.ts +142 -0
  121. package/src/contract/models.ts +343 -0
  122. package/src/contract/permissions.ts +117 -0
  123. package/src/contract/router.ts +375 -0
@@ -0,0 +1,142 @@
1
+ import type { ExportFormat, ExportScope, ImportSource, TransferState } from '../contract/index.js'
2
+ import { t } from './i18n.js'
3
+
4
+ /**
5
+ * The bits of the transfer screens that are not a screen.
6
+ *
7
+ * Two dialogs and a page draw the same four vocabularies — a scope, a format, a source and a state
8
+ * — and the same download. Written out once here because the alternative is four copies of a
9
+ * `switch` that has to agree with the contract's enums: a `TransferState` gaining a fifth member
10
+ * would then be four silent gaps rather than one type error.
11
+ *
12
+ * Every label is a function rather than a constant. A module is defined once at import time while
13
+ * the interface language can change afterwards, so a map built at module scope would be frozen in
14
+ * whatever language happened to be loaded first — the same reason `module.ts` writes its nav labels
15
+ * as getters.
16
+ */
17
+
18
+ /** The four states, as a person reads them rather than as the column stores them. */
19
+ export function stateLabel(state: TransferState): string {
20
+ return state === 'queued'
21
+ ? t('transfer_queued')
22
+ : state === 'running'
23
+ ? t('transfer_running')
24
+ : state === 'done'
25
+ ? t('transfer_done')
26
+ : t('transfer_failed')
27
+ }
28
+
29
+ /**
30
+ * What a state looks like at a glance.
31
+ *
32
+ * `queued` and `running` share the spinner deliberately: the difference between "a worker has not
33
+ * picked this up" and "a worker is on it" is the sentence beside the icon, not the icon — two
34
+ * different in-progress glyphs would be two things to learn for one meaning.
35
+ */
36
+ export function stateIcon(state: TransferState): string {
37
+ return state === 'done' ? 'circle-check' : state === 'failed' ? 'triangle-alert' : 'loader'
38
+ }
39
+
40
+ /** Where the export took its pages from. */
41
+ export function scopeLabel(scope: ExportScope): string {
42
+ return scope === 'page'
43
+ ? t('export_scope_page')
44
+ : scope === 'subtree'
45
+ ? t('export_scope_subtree')
46
+ : t('export_scope_space')
47
+ }
48
+
49
+ /**
50
+ * The formats a person may actually pick.
51
+ *
52
+ * `docx` is in `ExportFormat` and is not here, because the server refuses it at `exports.start` —
53
+ * see `services/export.ts` for why a Word file nothing in this repository can open is worse than a
54
+ * refusal. Offering a control whose only outcome is an error is the shape this list exists to
55
+ * prevent; the dialog says the same thing in a sentence instead, which answers the question
56
+ * ("where is Word?") without pretending to have an answer it does not have.
57
+ */
58
+ export const EXPORT_FORMATS: readonly Exclude<ExportFormat, 'docx'>[] = ['markdown', 'html', 'pdf']
59
+
60
+ export function formatLabel(format: ExportFormat): string {
61
+ return format === 'markdown'
62
+ ? t('export_format_markdown')
63
+ : format === 'html'
64
+ ? t('export_format_html')
65
+ : format === 'pdf'
66
+ ? t('export_format_pdf')
67
+ : t('export_format_docx')
68
+ }
69
+
70
+ export function formatDescription(format: Exclude<ExportFormat, 'docx'>): string {
71
+ return format === 'markdown'
72
+ ? t('export_format_markdown_desc')
73
+ : format === 'html'
74
+ ? t('export_format_html_desc')
75
+ : t('export_format_pdf_desc')
76
+ }
77
+
78
+ export const IMPORT_SOURCES: readonly ImportSource[] = ['notion', 'confluence', 'markdown']
79
+
80
+ export function sourceLabel(source: ImportSource): string {
81
+ return source === 'notion'
82
+ ? t('import_source_notion')
83
+ : source === 'confluence'
84
+ ? t('import_source_confluence')
85
+ : t('import_source_markdown')
86
+ }
87
+
88
+ export function sourceDescription(source: ImportSource): string {
89
+ return source === 'notion'
90
+ ? t('import_source_notion_desc')
91
+ : source === 'confluence'
92
+ ? t('import_source_confluence_desc')
93
+ : t('import_source_markdown_desc')
94
+ }
95
+
96
+ /** Neither `done` nor `failed`, so something is still going to happen to it. */
97
+ export const isRunning = (state: TransferState): boolean => state === 'queued' || state === 'running'
98
+
99
+ /**
100
+ * How far along a job is, as a fraction, or `null` while there is nothing honest to draw.
101
+ *
102
+ * `total` is 0 until the job has finished counting what it is about to do, and a bar sitting at 0%
103
+ * for the first few seconds of every export reads as a job that is stuck. Null means "say it is
104
+ * running, in words" — which is true — rather than drawing a measurement nobody has taken.
105
+ */
106
+ export function progressRatio(counts: {
107
+ total: number
108
+ done: number
109
+ skipped: number
110
+ failed: number
111
+ }): number | null {
112
+ if (counts.total <= 0) return null
113
+ const seen = counts.done + counts.skipped + counts.failed
114
+ return Math.max(0, Math.min(1, seen / counts.total))
115
+ }
116
+
117
+ /**
118
+ * Fetch an artefact the browser has just been given a link to.
119
+ *
120
+ * A hidden anchor rather than `location.href`, and rather than `window.open`: `open` is what a
121
+ * pop-up blocker stops when it happens after an `await`, and assigning `location` sends the whole
122
+ * document at a URL that only *usually* comes back as a download. A click on an `<a>` is the one
123
+ * gesture every browser treats as "fetch this file", and the page it happened on is untouched.
124
+ *
125
+ * `filename` is honoured for a same-origin address only — the `download` attribute is ignored
126
+ * cross-origin, which is every real instance, where the name comes from the `Content-Disposition`
127
+ * the presigned URL was signed with. It is passed anyway because `dev:mock` hands back a `data:`
128
+ * URL, and there the attribute is the only thing that names the file.
129
+ */
130
+ export function startDownload(url: string, filename?: string): void {
131
+ const anchor = document.createElement('a')
132
+ anchor.href = url
133
+ anchor.rel = 'noreferrer noopener'
134
+ if (filename) anchor.download = filename
135
+ anchor.style.display = 'none'
136
+ document.body.appendChild(anchor)
137
+ anchor.click()
138
+ anchor.remove()
139
+ }
140
+
141
+ /** An error as a line of text, whatever was thrown. */
142
+ export const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err))
@@ -321,4 +321,347 @@ export const Publication = z.object({
321
321
  })
322
322
  export type Publication = z.infer<typeof Publication>
323
323
 
324
+ // =====================================================================================
325
+ // Getting work in and out
326
+ // =====================================================================================
327
+
328
+ /**
329
+ * How much of the tree an export takes.
330
+ *
331
+ * `page` is one page; `subtree` is a page and everything under it; `space` is every page in a space.
332
+ * The three are not a convenience — they are the three questions people actually ask ("send me this",
333
+ * "send me this section", "get us off this product"), and the middle one is the one a flat
334
+ * page-by-page export cannot answer without somebody clicking three hundred times.
335
+ */
336
+ export const ExportScope = z.enum(['page', 'subtree', 'space'])
337
+ export type ExportScope = z.infer<typeof ExportScope>
338
+
339
+ /**
340
+ * What comes out.
341
+ *
342
+ * `html` and `pdf` are rendered by the same static renderer the public site uses — never the live
343
+ * document and never the draft, so an export of a `page` is what a reader is served rather than what
344
+ * the last person to open it happened to be typing.
345
+ */
346
+ export const ExportFormat = z.enum(['markdown', 'html', 'docx', 'pdf'])
347
+ export type ExportFormat = z.infer<typeof ExportFormat>
348
+
349
+ /** Where a transfer is. `done` and `failed` are terminal; nothing moves out of either. */
350
+ export const TransferState = z.enum(['queued', 'running', 'done', 'failed'])
351
+ export type TransferState = z.infer<typeof TransferState>
352
+
353
+ /**
354
+ * A job's own progress, shared by both directions.
355
+ *
356
+ * `skipped` is the counter that matters and the one a progress bar leaves out. An export skips a page
357
+ * the requester may not read; an import skips a file it cannot map. Reporting the count is the
358
+ * difference between an artefact that is quietly missing things and one that says how many.
359
+ *
360
+ * Every field defaults, so a job that has not started yet parses as four zeroes rather than as
361
+ * absent — a client should never have to distinguish "no progress" from "no counters".
362
+ */
363
+ export const TransferCounts = z.object({
364
+ total: z.number().int().nonnegative().default(0),
365
+ done: z.number().int().nonnegative().default(0),
366
+ skipped: z.number().int().nonnegative().default(0),
367
+ failed: z.number().int().nonnegative().default(0),
368
+ })
369
+ export type TransferCounts = z.infer<typeof TransferCounts>
370
+
371
+ /**
372
+ * A request to take a page, a subtree or a space out of Quire as a file.
373
+ *
374
+ * There is no artefact URL here and there should not be. `fileId` is an opaque id, not an address:
375
+ * the download is a signed URL a procedure mints per request, so that the fence on who may fetch a
376
+ * subtree export — which flattens pages of different readerships into one file — is applied at the
377
+ * moment of the fetch rather than baked into a link that outlives it. A client that builds a storage
378
+ * key from an id is the mistake `migrations/0009` had to go back and scrub out of published HTML.
379
+ */
380
+ export const ExportJob = z.object({
381
+ id: Id,
382
+ workspaceId: WorkspaceId,
383
+ requestedBy: UserId,
384
+ scope: ExportScope,
385
+ /** the page for `page` and `subtree`, the space for `space`; `scope` says which */
386
+ targetId: Id,
387
+ format: ExportFormat,
388
+ state: TransferState,
389
+ /** null until the job succeeds — an artefact that is still being written is not offered */
390
+ fileId: Id.nullable(),
391
+ /**
392
+ * Why it failed, in the words of whatever failed — a Gotenberg refusal, the name of a page that
393
+ * would not render. Diagnostic, not a user-facing string: a screen says its piece from a message
394
+ * key chosen by `state`, and shows this beside it for the person who has to act on it.
395
+ */
396
+ error: z.string().nullable(),
397
+ counts: TransferCounts,
398
+ createdAt: Timestamp,
399
+ /** null while `queued` or `running` */
400
+ finishedAt: Timestamp.nullable(),
401
+ })
402
+ export type ExportJob = z.infer<typeof ExportJob>
403
+
404
+ /** What kind of export is being read in. */
405
+ export const ImportSource = z.enum(['notion', 'confluence', 'markdown'])
406
+ export type ImportSource = z.infer<typeof ImportSource>
407
+
408
+ /**
409
+ * What happened to one file.
410
+ *
411
+ * Three outcomes and not two, because "not imported" covers two different situations a person needs
412
+ * to tell apart: a file Quire deliberately did not want (an asset already inlined, a Notion index
413
+ * page that duplicates the folder) is `skipped`, and a file that should have become a page and did
414
+ * not is `failed`. Collapsing them turns a broken import into a tidy-looking one.
415
+ */
416
+ export const ImportOutcome = z.enum(['imported', 'skipped', 'failed'])
417
+ export type ImportOutcome = z.infer<typeof ImportOutcome>
418
+
419
+ /**
420
+ * One row of the report: what the file was, what became of it, and which.
421
+ *
422
+ * The failure list is the feature. A real Notion export has files that will not map, and an import
423
+ * that silently drops forty pages is worse than one that refuses — so every file in the upload gets
424
+ * an entry, including the ones that worked, and the report is complete rather than a list of
425
+ * complaints. `path` is the path inside the archive, which is what somebody has to go and look at.
426
+ */
427
+ export const ImportReportEntry = z.object({
428
+ /** the file's path inside the uploaded archive, exactly as it appeared */
429
+ path: z.string(),
430
+ outcome: ImportOutcome,
431
+ /** the page it became, for `imported`; null for the other two */
432
+ pageId: Id.nullable().default(null),
433
+ /**
434
+ * Why, for `skipped` and `failed`; null for `imported`.
435
+ *
436
+ * Free text on purpose: the reasons are as varied as the exports people have, and a closed enum
437
+ * here would either be wrong within a week or force every unexpected file into an `other` that
438
+ * tells nobody anything.
439
+ */
440
+ reason: z.string().nullable().default(null),
441
+ })
442
+ export type ImportReportEntry = z.infer<typeof ImportReportEntry>
443
+
444
+ /**
445
+ * A Notion export zip, a Confluence export or a folder of Markdown, on its way into one space.
446
+ *
447
+ * `sourceFileId` is the upload, not an artefact — the opposite direction from `ExportJob.fileId`, and
448
+ * named differently for that reason. An import produces pages, not a file.
449
+ *
450
+ * Internal links between imported pages are rewritten to Quire page ids as the import goes; a link
451
+ * that cannot be resolved becomes plain text rather than a dead link, and the page that carried it
452
+ * still counts as `imported`. That choice is why `report` is per *file* and not per *link*: a page
453
+ * that arrived with one unresolvable link is an imported page, not a failure.
454
+ */
455
+ export const ImportJob = z.object({
456
+ id: Id,
457
+ workspaceId: WorkspaceId,
458
+ requestedBy: UserId,
459
+ source: ImportSource,
460
+ /** the space being written into. An import always targets one space; there is no scope here. */
461
+ targetId: Id,
462
+ /** the uploaded archive — consumed by the job, and present before the job exists */
463
+ sourceFileId: Id,
464
+ state: TransferState,
465
+ /** why the *job* failed; why one *file* failed is that file's entry in `report` */
466
+ error: z.string().nullable(),
467
+ counts: TransferCounts,
468
+ /** one entry per file in the upload, in the order they were read */
469
+ report: z.array(ImportReportEntry),
470
+ createdAt: Timestamp,
471
+ /** null while `queued` or `running` */
472
+ finishedAt: Timestamp.nullable(),
473
+ })
474
+ export type ImportJob = z.infer<typeof ImportJob>
475
+
476
+ // =====================================================================================
477
+ // Templates
478
+ // =====================================================================================
479
+
480
+ /**
481
+ * What a template makes.
482
+ *
483
+ * `page` — one page; the body is a page doc.
484
+ * `space` — a whole space; the body is a tree of pages, each with its own body.
485
+ *
486
+ * Two rather than one with a "just make the root" flag, because they are made from different places
487
+ * and answer different questions: a page template is offered on "New page" inside a space that
488
+ * already exists, and a space template is what somebody reaches for when there is no space yet.
489
+ */
490
+ export const TemplateKind = z.enum(['page', 'space'])
491
+ export type TemplateKind = z.infer<typeof TemplateKind>
492
+
493
+ /**
494
+ * The starters this module ships.
495
+ *
496
+ * **They are constants in the module, not rows in a customer's database**, and the key is what a
497
+ * constant has instead of an id. `migrations/0011_templates.sql` argues the choice at length; the
498
+ * short of it is that a migration runs once per *database* and has no workspace to seed into, that a
499
+ * seeded row is frozen at the release that wrote it, and that a row holds one language in a product
500
+ * that ships five.
501
+ *
502
+ * A workspace edits a starter by **overriding** it: a `Template` row carrying one of these keys takes
503
+ * that starter's place in the picker rather than sitting beside it, so the first edit writes one row,
504
+ * a workspace that never touches them has none, and resetting is deleting the row.
505
+ *
506
+ * `Template.key` is deliberately **not** this enum — see the comment there.
507
+ */
508
+ export const TemplateStarterKey = z.enum([
509
+ 'meeting-notes',
510
+ 'decision-record',
511
+ 'requirements',
512
+ 'retrospective',
513
+ 'how-to',
514
+ ])
515
+ export type TemplateStarterKey = z.infer<typeof TemplateStarterKey>
516
+ export const TEMPLATE_STARTER_KEYS = TemplateStarterKey.options
517
+
518
+ /**
519
+ * What a variable's type decides, which is **the control somebody is shown and not the storage**.
520
+ *
521
+ * Every filled value is substituted into prose as text, whatever the type: `{{sprint}}` in a heading
522
+ * becomes characters in a heading. The type is what turns "type the date" into a date picker and
523
+ * "type one of these four" into a menu — the difference between a form somebody fills correctly and
524
+ * one they fill approximately.
525
+ */
526
+ export const TemplateVariableType = z.enum(['text', 'number', 'date', 'select', 'user'])
527
+ export type TemplateVariableType = z.infer<typeof TemplateVariableType>
528
+
529
+ /**
530
+ * The names a template author may not take, because the module already fills them.
531
+ *
532
+ * `date` and `author` are the two the plan names. The other three are reserved now rather than
533
+ * later, and that is the whole reason the list is longer than it needs to be today: adding
534
+ * `{{time}}` in a future release would silently change what an existing template renders if some
535
+ * author had already declared a variable of that name. Reserving a name costs an author one
536
+ * synonym; taking one back costs somebody a document that used to be right.
537
+ */
538
+ export const TEMPLATE_BUILT_IN_VARIABLES = ['date', 'time', 'author', 'space', 'workspace'] as const
539
+
540
+ /**
541
+ * What appears between the braces.
542
+ *
543
+ * ASCII, lowercase, no spaces — the same shape as `Space.key` and for the same reason: this string
544
+ * is matched against the body, so `{{Sprint}}` and `{{sprint}}` being two variables is exactly the
545
+ * near-duplicate that `labels_ws_space_name_uq` refuses next door. An author's own language belongs
546
+ * in `label`, which is the half a person actually reads.
547
+ */
548
+ export const TemplateVariableName = z
549
+ .string()
550
+ .min(1)
551
+ .max(40)
552
+ .regex(/^[a-z][a-z0-9_]*$/, 'lowercase letters, digits and underscores, starting with a letter')
553
+ .refine(
554
+ (name) => !(TEMPLATE_BUILT_IN_VARIABLES as readonly string[]).includes(name),
555
+ 'that name is filled by the module itself',
556
+ )
557
+
558
+ /**
559
+ * One thing somebody is asked before the page is made.
560
+ *
561
+ * Every field but `name`, `label` and `type` defaults, so a variable written by an older client
562
+ * still parses. Note what that does to the *output* type: it makes them required, so anything
563
+ * constructing a variable supplies all six — which is the point, and the reason a widened contract
564
+ * is additive for parsing and breaking for constructing.
565
+ */
566
+ export const TemplateVariable = z.object({
567
+ name: TemplateVariableName,
568
+ /** what the person filling it in reads; their own language goes here, not in `name` */
569
+ label: z.string().min(1).max(120),
570
+ type: TemplateVariableType,
571
+ /** the menu, for `select`; empty for every other type */
572
+ options: z.array(z.string().min(1).max(120)).max(50).default([]),
573
+ /** what the field starts with — text whatever the type, because substitution is textual */
574
+ default: z.string().max(2000).nullable().default(null),
575
+ /** whether the page can be made without it */
576
+ required: z.boolean().default(false),
577
+ })
578
+ export type TemplateVariable = z.infer<typeof TemplateVariable>
579
+
580
+ /**
581
+ * One page of a space template, and everything under it.
582
+ *
583
+ * Recursive because a space is a tree and flattening it into a list with parent pointers would mean
584
+ * inventing local ids that exist only inside the column — an identifier nothing outside this
585
+ * document ever resolves. The depth is bounded by the space it was made from.
586
+ *
587
+ * The type is written out and the schema annotated with it, rather than left to inference, because
588
+ * a self-referencing `const` has no type TypeScript can name on its own and the package emits
589
+ * declarations.
590
+ */
591
+ export type TemplateSpaceNode = {
592
+ title: string
593
+ icon: string | null
594
+ doc: Record<string, unknown>
595
+ children: TemplateSpaceNode[]
596
+ }
597
+ export const TemplateSpaceNode: z.ZodType<TemplateSpaceNode, TemplateSpaceNode> = z.lazy(() =>
598
+ z.object({
599
+ title: z.string().max(300),
600
+ icon: z.string().max(64).nullable(),
601
+ /** the page's body, with the same `{{variables}}` in it as any other */
602
+ doc: RichDoc,
603
+ children: z.array(TemplateSpaceNode),
604
+ }),
605
+ )
606
+
607
+ /**
608
+ * The body of a `space` template: the tree, under one key.
609
+ *
610
+ * An object rather than a bare array so that `Template.doc` is a JSON object for both kinds — the
611
+ * column defaults to `'{}'`, and a default that is the wrong *type* for half the rows is a default
612
+ * that lies. It also means the two bodies are told apart by their keys rather than by `typeof`.
613
+ */
614
+ export const TemplateSpaceBody = z.object({ pages: z.array(TemplateSpaceNode) })
615
+ export type TemplateSpaceBody = z.infer<typeof TemplateSpaceBody>
616
+
617
+ /**
618
+ * A page, or a whole space, saved so it can be made again.
619
+ *
620
+ * `doc` is left opaque here for the reason `RichDoc` is: this file is not what knows the shape of a
621
+ * document. It is a page doc when `kind` is `page` and a `TemplateSpaceBody` when `kind` is `space`,
622
+ * and the server parses the second with the schema above.
623
+ *
624
+ * A shipped starter is not one of these — it has no id, no workspace and no timestamps, because it
625
+ * is a constant. What the picker returns is the starters and these together, with a row carrying a
626
+ * starter's `key` standing in for that starter.
627
+ */
628
+ export const Template = z.object({
629
+ id: Id,
630
+ workspaceId: WorkspaceId,
631
+ /** null is workspace-wide; a space id scopes it to one space. Always null when `kind` is `space`. */
632
+ spaceId: Id.nullable(),
633
+ kind: TemplateKind,
634
+ /**
635
+ * The starter this row replaces, or null for somebody's own template.
636
+ *
637
+ * `z.string()` and **not** `TemplateStarterKey`, deliberately. This is stored data: a release that
638
+ * stopped shipping a starter would turn every override of it into a parse failure, which is a
639
+ * picker that throws rather than a picker missing one entry. A key naming a starter that no longer
640
+ * exists is an ordinary custom template, and the read side treats it as one.
641
+ */
642
+ key: z.string().min(1).max(64).nullable(),
643
+ /** true exactly when `key` is set — the database holds the pair to that with a check constraint */
644
+ builtIn: z.boolean(),
645
+ name: z.string().min(1).max(120),
646
+ description: z.string().max(2000),
647
+ /** a Lucide icon name, or an emoji */
648
+ icon: z.string().max(64).nullable(),
649
+ doc: RichDoc,
650
+ variables: z.array(TemplateVariable),
651
+ createdBy: UserId.nullable(),
652
+ createdAt: Timestamp,
653
+ updatedAt: Timestamp,
654
+ })
655
+ export type Template = z.infer<typeof Template>
656
+
657
+ /**
658
+ * A template without its body.
659
+ *
660
+ * This exists so that opening a picker does not download every template's prose to draw a list of
661
+ * names — thirty page docs to render thirty rows, of which one is ever used. The body is fetched
662
+ * when a template is chosen.
663
+ */
664
+ export const TemplateSummary = Template.omit({ doc: true })
665
+ export type TemplateSummary = z.infer<typeof TemplateSummary>
666
+
324
667
  export const Ok = z.object({ ok: z.literal(true) })
@@ -64,6 +64,56 @@ export const quirePermissions = definePermissions([
64
64
  defaultRoles: ['owner', 'admin', 'member'],
65
65
  dangerous: false,
66
66
  },
67
+ /**
68
+ * Taking pages out of Kern as a file.
69
+ *
70
+ * A separate key from `quire.page.view` rather than a consequence of it, and the distinction is
71
+ * one administrators actually make: reading a handbook a page at a time and walking out with the
72
+ * whole thing in a zip are different acts, and the second is the one a leaver does on their last
73
+ * afternoon. Every page in an export is still checked against `quire.page.view` as it is written,
74
+ * so this grants nothing extra — it decides whether the bulk shape is available at all.
75
+ *
76
+ * Not `dangerous`, because it destroys nothing and every page in the result is one the exporter
77
+ * could already open. The import side is the dangerous half: that one writes.
78
+ *
79
+ * A guest is deliberately not in the default set. A guest is somebody invited to read one thing,
80
+ * and the difference between reading it and keeping a copy of the section around it is exactly
81
+ * what a guest is a guest for.
82
+ */
83
+ {
84
+ key: 'quire.page.export',
85
+ label: 'Export pages',
86
+ description: 'Take a page, a section or a space out as Markdown, HTML or PDF',
87
+ scope: 'space',
88
+ defaultRoles: ['owner', 'admin', 'member'],
89
+ dangerous: false,
90
+ },
91
+ /**
92
+ * Bringing pages in from somewhere else, in bulk.
93
+ *
94
+ * **`dangerous`, where the export half is not**, and the asymmetry is the whole reason this is a
95
+ * separate key rather than `quire.page.create` applied a thousand times. An export reads pages the
96
+ * exporter could already open; an import *writes* — hundreds of pages, a page tree, databases with
97
+ * their columns — into a space, in one act, from a file nobody in the workspace has read. Undoing
98
+ * it means finding every page it made and trashing them, which is not a button. The tracker marks
99
+ * `tracker.import.run` the same way and for the same reason.
100
+ *
101
+ * Owner and admin only, and that is a deliberate step up from `quire.page.create`. Somebody who may
102
+ * write in a space is not thereby somebody who may reshape it: an ordinary member creating pages
103
+ * makes them one at a time, with the tree in front of them.
104
+ *
105
+ * It is not a substitute for the narrower keys. An import still writes only where the requester
106
+ * holds this permission *on that space*, and the job re-asks when it runs rather than trusting the
107
+ * answer from when it was queued — a permission taken away between the two is a job that fails.
108
+ */
109
+ {
110
+ key: 'quire.page.import',
111
+ label: 'Import pages',
112
+ description: 'Bring a Notion, Confluence or Markdown export into a space as pages',
113
+ scope: 'space',
114
+ defaultRoles: ['owner', 'admin'],
115
+ dangerous: true,
116
+ },
67
117
  {
68
118
  key: 'quire.page.delete',
69
119
  label: 'Delete pages permanently',
@@ -205,6 +255,73 @@ export const quireProcedureAuthz: Record<string, ProcedureAuthz> = {
205
255
  'databases.removeView': { check: 'page', permission: 'quire.page.edit' },
206
256
  'databases.setRelation': { check: 'page', permission: 'quire.page.edit' },
207
257
 
258
+ /*
259
+ * `start` is declared `page` because that is the branch that can be bound narrowly and therefore
260
+ * the branch worth proving: a `page` or `subtree` export resolves the target page's own ancestor
261
+ * chain and asks about it, so a page-scoped DENY of `quire.page.export` refuses the export of that
262
+ * page and of any section containing it. A `space` export has no one page to resolve — it is the
263
+ * whole space — so that branch asks the same permission at space scope, which is the narrowest
264
+ * scope that exists for it. `export.int.test.ts` covers the space branch against a space-scoped
265
+ * DENY, because the sweep in `authz.int.test.ts` only ever sends the page branch.
266
+ *
267
+ * `get` and `list` are `workspace` for the same reason `favorites.list` is: "my own exports" has
268
+ * no narrower scope. What keeps them private is not a permission at all but the `requested_by`
269
+ * filter in the query — row-level security fences the tenant, and a tenant is not a person.
270
+ */
271
+ 'exports.start': { check: 'page', permission: 'quire.page.export' },
272
+ 'exports.get': { check: 'workspace', permission: 'quire.page.export' },
273
+ 'exports.list': { check: 'workspace', permission: 'quire.page.export' },
274
+
275
+ /*
276
+ * `start` is `space` where its export counterpart is `page`, and the difference is not an
277
+ * oversight. An import has no page to be scoped to — it *creates* the pages — so the space it
278
+ * writes into is the narrowest scope that exists for it, and a space-scoped DENY of
279
+ * `quire.page.import` is what has to refuse it. The check is asked twice on purpose: once here,
280
+ * before a row is recorded, and again inside the job as the person who asked, because a job runs
281
+ * minutes later and a permission can be taken away in between.
282
+ *
283
+ * `get` and `list` are `workspace` for the same reason `exports.get` and `favorites.list` are:
284
+ * "my own imports" has no narrower scope. What keeps them private is the `requested_by` filter in
285
+ * the query rather than a permission — row-level security fences the tenant, and a tenant is not
286
+ * a person.
287
+ */
288
+ 'imports.start': { check: 'space', permission: 'quire.page.import' },
289
+ 'imports.get': { check: 'workspace', permission: 'quire.page.import' },
290
+ 'imports.list': { check: 'workspace', permission: 'quire.page.import' },
291
+
292
+ /*
293
+ * A template is a space's furniture, so it follows `labels.*` exactly: reading is
294
+ * `quire.space.view` and writing is `quire.space.manage`. Changing one changes what everybody in
295
+ * the space is offered the next time they make a page, which is not a thing somebody who may edit
296
+ * one page should be able to do to everybody else's.
297
+ *
298
+ * Three of the five are `check: 'space'` although four of them take a *template* id, not a space
299
+ * id. A template id carries no scope of its own, so the row is read first and the question is
300
+ * asked about the space it belongs to — the same shape as `labels.update` next door. A
301
+ * workspace-wide template (`space_id is null`) has no narrower scope than the workspace, and the
302
+ * handlers say so out loud rather than silently falling back to the space of whoever asked.
303
+ *
304
+ * `createFromPage` is the one that is `check: 'page'`, and it is the interesting one: it *copies a
305
+ * page's prose into a template*, so the question that actually protects anything is whether this
306
+ * person may read that page. A page-scoped DENY of `quire.page.view` has to refuse it, or a
307
+ * contractor allowed one page of a handbook could lift a page they cannot open into a template and
308
+ * read it there. `quire.space.manage` is asked as well, in the same handler, and
309
+ * `templates.int.test.ts` covers that half — this column names one permission, and the one worth
310
+ * proving with a page-scoped binding is the read.
311
+ *
312
+ * `instantiate` asks `quire.page.create` and not `space.manage`: what it does is make a page.
313
+ * Somebody who may write in a space may use its templates; only somebody who configures the space
314
+ * may change them. Its `space` branch — which makes a *space* — asks `quire.space.manage` at
315
+ * workspace scope on top, for the same reason `spaces.create` does; the sweep sends the page
316
+ * branch, and `templates.int.test.ts` covers the other.
317
+ */
318
+ 'templates.list': { check: 'space', permission: 'quire.space.view' },
319
+ 'templates.get': { check: 'space', permission: 'quire.space.view' },
320
+ 'templates.createFromPage': { check: 'page', permission: 'quire.page.view' },
321
+ 'templates.update': { check: 'space', permission: 'quire.space.manage' },
322
+ 'templates.remove': { check: 'space', permission: 'quire.space.manage' },
323
+ 'templates.instantiate': { check: 'space', permission: 'quire.page.create' },
324
+
208
325
  'publishing.publish': { check: 'page', permission: 'quire.page.publish' },
209
326
  'publishing.revert': { check: 'page', permission: 'quire.page.edit' },
210
327