@kernhq/module-quire 0.13.1 → 0.14.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 (97) hide show
  1. package/dist/contract/models.d.ts +185 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +133 -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 +82 -0
  8. package/dist/contract/permissions.js.map +1 -1
  9. package/dist/contract/properties.d.ts +4 -4
  10. package/dist/contract/router.d.ts +535 -8
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +133 -1
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +519 -8
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +128 -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 +312 -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/schema.d.ts +445 -1
  62. package/dist/server/schema.d.ts.map +1 -1
  63. package/dist/server/schema.js +146 -0
  64. package/dist/server/schema.js.map +1 -1
  65. package/dist/server/services/databases.d.ts +5 -5
  66. package/dist/server/services/export.d.ts +176 -0
  67. package/dist/server/services/export.d.ts.map +1 -0
  68. package/dist/server/services/export.js +822 -0
  69. package/dist/server/services/export.js.map +1 -0
  70. package/dist/server/services/import.d.ts +109 -0
  71. package/dist/server/services/import.d.ts.map +1 -0
  72. package/dist/server/services/import.js +570 -0
  73. package/dist/server/services/import.js.map +1 -0
  74. package/dist/server/services/index.d.ts +26 -1
  75. package/dist/server/services/index.d.ts.map +1 -1
  76. package/dist/server/services/index.js +60 -1
  77. package/dist/server/services/index.js.map +1 -1
  78. package/dist/server/services/versions.d.ts +1 -1
  79. package/migrations/0010_transfers.sql +154 -0
  80. package/migrations/meta/_journal.json +7 -0
  81. package/package.json +1 -1
  82. package/src/client/components/ExportDialog.svelte +685 -0
  83. package/src/client/components/ImportDialog.svelte +702 -0
  84. package/src/client/components/ImportReport.svelte +310 -0
  85. package/src/client/components/SidebarSpaces.svelte +79 -0
  86. package/src/client/i18n.ts +614 -0
  87. package/src/client/index.ts +32 -0
  88. package/src/client/mock.ts +318 -0
  89. package/src/client/module.ts +36 -0
  90. package/src/client/pages/PageView.svelte +36 -0
  91. package/src/client/pages/TransfersPage.svelte +570 -0
  92. package/src/client/permissions.ts +11 -0
  93. package/src/client/query.ts +23 -0
  94. package/src/client/transfers.ts +142 -0
  95. package/src/contract/models.ts +152 -0
  96. package/src/contract/permissions.ts +84 -0
  97. package/src/contract/router.ts +147 -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,156 @@ 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
+
324
476
  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,40 @@ 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
+
208
292
  'publishing.publish': { check: 'page', permission: 'quire.page.publish' },
209
293
  'publishing.revert': { check: 'page', permission: 'quire.page.edit' },
210
294
 
@@ -3,7 +3,12 @@ import { z } from 'zod'
3
3
  import {
4
4
  CommentAnchor,
5
5
  CommentThread,
6
+ ExportFormat,
7
+ ExportJob,
8
+ ExportScope,
6
9
  Favorite,
10
+ ImportJob,
11
+ ImportSource,
7
12
  Label,
8
13
  LabelColour,
9
14
  Ok,
@@ -184,6 +189,34 @@ export type PublicSearchHit = z.infer<typeof PublicSearchHit>
184
189
 
185
190
  export const PublicSitemapEntry = z.object({ path: z.string(), lastModified: Timestamp })
186
191
 
192
+ /**
193
+ * An export job, plus the one thing that cannot be stored on it: a link to the artefact.
194
+ *
195
+ * `downloadUrl` is minted per request and is null in every state but `done`. It is composed here
196
+ * rather than written into `export_jobs` because a signed storage URL is the object's key, so a
197
+ * stored one is both an address that leaks the workspace and file uuids and an address that stops
198
+ * working an hour later — and, worse, a fence applied once at the moment the job finished rather
199
+ * than every time somebody asks. A subtree export flattens pages of different readerships into one
200
+ * file, so the moment of the fetch is the moment that has to be checked.
201
+ */
202
+ export const ExportJobDetail = ExportJob.extend({ downloadUrl: z.string().nullable() })
203
+ export type ExportJobDetail = z.infer<typeof ExportJobDetail>
204
+
205
+ /**
206
+ * An import job **without its report**, which is the only shape a list can afford to carry.
207
+ *
208
+ * The report is one row per file, and a Notion export is thousands of files — so twenty jobs in a
209
+ * list is a response of megabytes to draw a table of dates and states. `get` carries the whole thing
210
+ * because that screen is somebody looking at one import and asking what happened to their files,
211
+ * which is the question the report exists to answer.
212
+ *
213
+ * Omitted rather than truncated on purpose. A report cut off at fifty rows is one that has quietly
214
+ * stopped being the complete account of the archive, and a person reading "23 skipped" beside a list
215
+ * of twelve would have no way to know which. Absent is a state a client can render; incomplete is not.
216
+ */
217
+ export const ImportJobSummary = ImportJob.omit({ report: true })
218
+ export type ImportJobSummary = z.infer<typeof ImportJobSummary>
219
+
187
220
  /**
188
221
  * One picture from a published page, **as bytes rather than as an address**.
189
222
  *
@@ -781,6 +814,120 @@ export const quireContract = {
781
814
  .output(z.object({ pageId: Id, excluded: z.boolean() })),
782
815
  },
783
816
 
817
+ /**
818
+ * Taking work out: a page, a page and everything under it, or a whole space, as a file.
819
+ *
820
+ * Three procedures and no fourth, which is the shape worth explaining. There is no `download`:
821
+ * `get` carries a `downloadUrl` it mints as it answers, so the permission is checked on the
822
+ * request that fetches the file rather than baked into a link that outlives the check. And there
823
+ * is no `cancel`: a job either finishes or fails, both terminal, and a cancel that races a worker
824
+ * writing an artefact is a way to end up with an object nothing points at.
825
+ *
826
+ * **What the artefact contains is decided by the job, not by the caller.** `start` names a scope
827
+ * and a format; every page under that scope is then checked against the requester's own
828
+ * `quire.page.view`, and a page they may not read is left out and counted in `skipped`. So a
829
+ * subtree export by somebody with a page-scoped DENY is a smaller file, not a refusal — and
830
+ * `counts.skipped` is how they find out, which is the difference between an export that is quietly
831
+ * missing pages and one that says how many.
832
+ *
833
+ * `format: 'docx'` is declared in `ExportFormat` and refused by `start` today: see the note in
834
+ * `services/export.ts` for what makes a *correct* Word file more than a matter of effort, and why
835
+ * a refusal is better than a document that may not open.
836
+ */
837
+ exports: {
838
+ /** Queue one. The answer is a row to watch, never the file — see the note above. */
839
+ start: baseContract
840
+ .route({ method: 'POST', path: '/exports', ...t('exports') })
841
+ .input(
842
+ ws.extend({
843
+ scope: ExportScope,
844
+ /** the page for `page` and `subtree`, the space for `space`; `scope` says which */
845
+ targetId: Id,
846
+ format: ExportFormat,
847
+ }),
848
+ )
849
+ .output(ExportJobDetail),
850
+ /** Where it has got to, and — once it is `done` — a link that is good for a few minutes. */
851
+ get: baseContract
852
+ .route({ method: 'GET', path: '/exports/{jobId}', ...t('exports') })
853
+ .input(ws.extend({ jobId: Id }))
854
+ .output(ExportJobDetail),
855
+ /**
856
+ * This person's own exports, newest first.
857
+ *
858
+ * Deliberately not the workspace's. Row-level security fences the tenant, which is not a privacy
859
+ * boundary, so the `requested_by` filter in the query is the only thing that keeps one person's
860
+ * export of the salary handbook out of everybody else's list — the same rule `favorites.list`
861
+ * and `recents.list` follow.
862
+ */
863
+ list: baseContract
864
+ .route({ method: 'GET', path: '/exports', ...t('exports') })
865
+ .input(ws.extend({ limit: z.number().int().min(1).max(50).default(20) }))
866
+ .output(z.array(ExportJob)),
867
+ },
868
+
869
+ /**
870
+ * Getting work in: a Notion export, a Confluence export or a folder of Markdown, into one space.
871
+ *
872
+ * **The failure list is the feature.** A real export has files that will not map — an attachment,
873
+ * a `.csv` with no header, a page whose link points outside what was exported — and an import that
874
+ * silently drops forty pages is worse than one that refuses. So every file in the archive gets a
875
+ * row in `report` saying whether it became a page, was deliberately left out, or could not be read,
876
+ * and `counts.total` is exactly the number of rows: nothing in the archive goes unaccounted for.
877
+ *
878
+ * Three things follow from the archive being read whole before anything is written, and each is
879
+ * worth knowing before reading the handlers:
880
+ *
881
+ * - **an import is all or nothing.** A zip that fails half way leaves the space exactly as it
882
+ * was, because the plan is built in memory and written in one transaction;
883
+ * - **links between imported pages are rewritten to Quire page ids**, which needs every id to
884
+ * exist before any body is resolved — a link to a page further down the archive is the normal
885
+ * case, not the exception. A link that resolves to nothing becomes plain text rather than a
886
+ * dead link, and the report names the target it could not find;
887
+ * - **a `.csv` becomes a database with typed columns**, guessed from the values and reported.
888
+ *
889
+ * `quire.page.import` is `dangerous`, as the tracker marks its own imports: this is the one thing
890
+ * in the module that writes hundreds of pages into a space in one act, and it is not undone by
891
+ * pressing something.
892
+ */
893
+ imports: {
894
+ /**
895
+ * Queue one. The answer is a row to watch; nothing has been written to the space yet.
896
+ *
897
+ * `fileId` is an upload — a core file the browser has already put in place — and not the archive
898
+ * itself. A zip is up to a few hundred megabytes, which is a file to be uploaded and then named,
899
+ * never a request body.
900
+ */
901
+ start: baseContract
902
+ .route({ method: 'POST', path: '/imports', ...t('imports') })
903
+ .input(
904
+ ws.extend({
905
+ /** the space being written into; an import always targets exactly one */
906
+ spaceId: Id,
907
+ source: ImportSource,
908
+ /** the uploaded archive, and not something this job produces — see `ImportJob` */
909
+ fileId: Id,
910
+ }),
911
+ )
912
+ .output(ImportJob),
913
+ /** Where it has got to, and — once it has finished — what happened to every file in it. */
914
+ get: baseContract
915
+ .route({ method: 'GET', path: '/imports/{jobId}', ...t('imports') })
916
+ .input(ws.extend({ jobId: Id }))
917
+ .output(ImportJob),
918
+ /**
919
+ * This person's own imports, newest first, without their reports — see `ImportJobSummary`.
920
+ *
921
+ * Deliberately not the workspace's, for the same reason `exports.list` is not: row-level security
922
+ * fences the tenant, which is not a privacy boundary, so the `requested_by` filter in the query
923
+ * is the only thing that keeps one person's import out of everybody else's list.
924
+ */
925
+ list: baseContract
926
+ .route({ method: 'GET', path: '/imports', ...t('imports') })
927
+ .input(ws.extend({ limit: z.number().int().min(1).max(50).default(20) }))
928
+ .output(z.array(ImportJobSummary)),
929
+ },
930
+
784
931
  /**
785
932
  * The signed-out surface. **This is the only part of Kern with no principal behind it.**
786
933
  *