@kernhq/module-quire 0.13.0 → 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.
- package/dist/contract/models.d.ts +185 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +133 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts +14 -0
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +82 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/properties.d.ts +4 -4
- package/dist/contract/router.d.ts +542 -15
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +155 -8
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +526 -15
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +166 -23
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/export/html.d.ts +67 -0
- package/dist/server/export/html.d.ts.map +1 -0
- package/dist/server/export/html.js +206 -0
- package/dist/server/export/html.js.map +1 -0
- package/dist/server/export/markdown.d.ts +51 -0
- package/dist/server/export/markdown.d.ts.map +1 -0
- package/dist/server/export/markdown.js +312 -0
- package/dist/server/export/markdown.js.map +1 -0
- package/dist/server/export/pdf.d.ts +20 -0
- package/dist/server/export/pdf.d.ts.map +1 -0
- package/dist/server/export/pdf.js +91 -0
- package/dist/server/export/pdf.js.map +1 -0
- package/dist/server/export/zip.d.ts +31 -0
- package/dist/server/export/zip.d.ts.map +1 -0
- package/dist/server/export/zip.js +158 -0
- package/dist/server/export/zip.js.map +1 -0
- package/dist/server/import/csv.d.ts +77 -0
- package/dist/server/import/csv.d.ts.map +1 -0
- package/dist/server/import/csv.js +263 -0
- package/dist/server/import/csv.js.map +1 -0
- package/dist/server/import/html.d.ts +52 -0
- package/dist/server/import/html.d.ts.map +1 -0
- package/dist/server/import/html.js +472 -0
- package/dist/server/import/html.js.map +1 -0
- package/dist/server/import/markdown.d.ts +63 -0
- package/dist/server/import/markdown.d.ts.map +1 -0
- package/dist/server/import/markdown.js +692 -0
- package/dist/server/import/markdown.js.map +1 -0
- package/dist/server/import/plan.d.ts +70 -0
- package/dist/server/import/plan.d.ts.map +1 -0
- package/dist/server/import/plan.js +761 -0
- package/dist/server/import/plan.js.map +1 -0
- package/dist/server/import/ydoc.d.ts +35 -0
- package/dist/server/import/ydoc.d.ts.map +1 -0
- package/dist/server/import/ydoc.js +91 -0
- package/dist/server/import/ydoc.js.map +1 -0
- package/dist/server/import/zip.d.ts +63 -0
- package/dist/server/import/zip.d.ts.map +1 -0
- package/dist/server/import/zip.js +308 -0
- package/dist/server/import/zip.js.map +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/schema.d.ts +445 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +146 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/databases.d.ts +5 -5
- package/dist/server/services/export.d.ts +176 -0
- package/dist/server/services/export.d.ts.map +1 -0
- package/dist/server/services/export.js +822 -0
- package/dist/server/services/export.js.map +1 -0
- package/dist/server/services/import.d.ts +109 -0
- package/dist/server/services/import.d.ts.map +1 -0
- package/dist/server/services/import.js +570 -0
- package/dist/server/services/import.js.map +1 -0
- package/dist/server/services/index.d.ts +28 -1
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +62 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/publications.d.ts +34 -1
- package/dist/server/services/publications.d.ts.map +1 -1
- package/dist/server/services/publications.js +62 -3
- package/dist/server/services/publications.js.map +1 -1
- package/dist/server/services/versions.d.ts +1 -1
- package/migrations/0010_transfers.sql +154 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/client/components/ExportDialog.svelte +685 -0
- package/src/client/components/ImportDialog.svelte +702 -0
- package/src/client/components/ImportReport.svelte +310 -0
- package/src/client/components/SidebarSpaces.svelte +79 -0
- package/src/client/i18n.ts +614 -0
- package/src/client/index.ts +32 -0
- package/src/client/mock.ts +318 -0
- package/src/client/module.ts +36 -0
- package/src/client/pages/PageView.svelte +36 -0
- package/src/client/pages/TransfersPage.svelte +570 -0
- package/src/client/permissions.ts +11 -0
- package/src/client/query.ts +23 -0
- package/src/client/transfers.ts +142 -0
- package/src/contract/models.ts +152 -0
- package/src/contract/permissions.ts +84 -0
- package/src/contract/router.ts +170 -7
|
@@ -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))
|
package/src/contract/models.ts
CHANGED
|
@@ -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
|
|
package/src/contract/router.ts
CHANGED
|
@@ -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,
|
|
@@ -32,6 +37,22 @@ import {
|
|
|
32
37
|
} from './properties.js'
|
|
33
38
|
|
|
34
39
|
const ws = z.object({ workspaceId: WorkspaceId })
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What names the workspace in a **public** URL: its id or its slug.
|
|
43
|
+
*
|
|
44
|
+
* The address the share dialog copies is `/p/<workspace-slug>/<publication-slug>/`, because a uuid
|
|
45
|
+
* in a link somebody sends a colleague is a receipt rather than an address. Every `public.*`
|
|
46
|
+
* procedure still needs an id before it touches `mod_quire` — anonymous means no principal, not no
|
|
47
|
+
* tenant — so the slug is resolved at the one anonymous entry point and everything downstream is
|
|
48
|
+
* unchanged. Widened here rather than parsed in the handler because a `z.uuid()` rejects the slug
|
|
49
|
+
* before any handler runs, which is how this shipped answering 404 for its own published URLs.
|
|
50
|
+
*/
|
|
51
|
+
const WorkspaceSegment = z
|
|
52
|
+
.string()
|
|
53
|
+
.min(1)
|
|
54
|
+
.max(64)
|
|
55
|
+
.regex(/^[0-9a-zA-Z][0-9a-zA-Z-]*$/, 'not a workspace id or slug')
|
|
35
56
|
const t = (...tags: string[]) => ({ tags })
|
|
36
57
|
|
|
37
58
|
/** The page fields a shortcut row draws, so a sidebar is one request rather than one per entry. */
|
|
@@ -168,6 +189,34 @@ export type PublicSearchHit = z.infer<typeof PublicSearchHit>
|
|
|
168
189
|
|
|
169
190
|
export const PublicSitemapEntry = z.object({ path: z.string(), lastModified: Timestamp })
|
|
170
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
|
+
|
|
171
220
|
/**
|
|
172
221
|
* One picture from a published page, **as bytes rather than as an address**.
|
|
173
222
|
*
|
|
@@ -765,6 +814,120 @@ export const quireContract = {
|
|
|
765
814
|
.output(z.object({ pageId: Id, excluded: z.boolean() })),
|
|
766
815
|
},
|
|
767
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
|
+
|
|
768
931
|
/**
|
|
769
932
|
* The signed-out surface. **This is the only part of Kern with no principal behind it.**
|
|
770
933
|
*
|
|
@@ -795,7 +958,7 @@ export const quireContract = {
|
|
|
795
958
|
.route({ method: 'GET', path: '/public/{workspaceId}/{slug}', ...t('public') })
|
|
796
959
|
.input(
|
|
797
960
|
z.object({
|
|
798
|
-
workspaceId:
|
|
961
|
+
workspaceId: WorkspaceSegment,
|
|
799
962
|
slug: Publication.shape.slug,
|
|
800
963
|
token: z.string().max(4096).nullable().default(null),
|
|
801
964
|
}),
|
|
@@ -811,7 +974,7 @@ export const quireContract = {
|
|
|
811
974
|
.route({ method: 'GET', path: '/public/{workspaceId}/{slug}/page', ...t('public') })
|
|
812
975
|
.input(
|
|
813
976
|
z.object({
|
|
814
|
-
workspaceId:
|
|
977
|
+
workspaceId: WorkspaceSegment,
|
|
815
978
|
slug: Publication.shape.slug,
|
|
816
979
|
/** '' is the front page */
|
|
817
980
|
path: z.string().max(1024).default(''),
|
|
@@ -832,7 +995,7 @@ export const quireContract = {
|
|
|
832
995
|
.route({ method: 'GET', path: '/public/{workspaceId}/{slug}/search', ...t('public') })
|
|
833
996
|
.input(
|
|
834
997
|
z.object({
|
|
835
|
-
workspaceId:
|
|
998
|
+
workspaceId: WorkspaceSegment,
|
|
836
999
|
slug: Publication.shape.slug,
|
|
837
1000
|
q: z.string().min(2).max(200),
|
|
838
1001
|
limit: z.number().int().min(1).max(50).default(20),
|
|
@@ -846,7 +1009,7 @@ export const quireContract = {
|
|
|
846
1009
|
*/
|
|
847
1010
|
sitemap: baseContract
|
|
848
1011
|
.route({ method: 'GET', path: '/public/{workspaceId}/{slug}/sitemap', ...t('public') })
|
|
849
|
-
.input(z.object({ workspaceId:
|
|
1012
|
+
.input(z.object({ workspaceId: WorkspaceSegment, slug: Publication.shape.slug }))
|
|
850
1013
|
.output(z.object({ entries: z.array(PublicSitemapEntry) })),
|
|
851
1014
|
/**
|
|
852
1015
|
* The one procedure here that never distinguishes one slug from another.
|
|
@@ -859,7 +1022,7 @@ export const quireContract = {
|
|
|
859
1022
|
*/
|
|
860
1023
|
robots: baseContract
|
|
861
1024
|
.route({ method: 'GET', path: '/public/{workspaceId}/{slug}/robots', ...t('public') })
|
|
862
|
-
.input(z.object({ workspaceId:
|
|
1025
|
+
.input(z.object({ workspaceId: WorkspaceSegment, slug: Publication.shape.slug }))
|
|
863
1026
|
.output(z.object({ indexable: z.boolean(), sitemapPath: z.string().nullable() })),
|
|
864
1027
|
/**
|
|
865
1028
|
* The bytes of one picture on a published page.
|
|
@@ -879,7 +1042,7 @@ export const quireContract = {
|
|
|
879
1042
|
.route({ method: 'GET', path: '/public/{workspaceId}/{slug}/asset', ...t('public') })
|
|
880
1043
|
.input(
|
|
881
1044
|
z.object({
|
|
882
|
-
workspaceId:
|
|
1045
|
+
workspaceId: WorkspaceSegment,
|
|
883
1046
|
slug: Publication.shape.slug,
|
|
884
1047
|
asset: z.string().min(1).max(2048),
|
|
885
1048
|
token: z.string().max(4096).nullable().default(null),
|
|
@@ -891,7 +1054,7 @@ export const quireContract = {
|
|
|
891
1054
|
.route({ method: 'POST', path: '/public/{workspaceId}/{slug}/unlock', ...t('public') })
|
|
892
1055
|
.input(
|
|
893
1056
|
z.object({
|
|
894
|
-
workspaceId:
|
|
1057
|
+
workspaceId: WorkspaceSegment,
|
|
895
1058
|
slug: Publication.shape.slug,
|
|
896
1059
|
password: z.string().min(1).max(200),
|
|
897
1060
|
}),
|