@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.
- package/dist/contract/models.d.ts +394 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +284 -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 +114 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/properties.d.ts +7 -7
- package/dist/contract/router.d.ts +1124 -96
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +343 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +983 -1168
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +308 -0
- 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 +351 -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/render.d.ts +158 -0
- package/dist/server/render.d.ts.map +1 -1
- package/dist/server/render.js +240 -0
- package/dist/server/render.js.map +1 -1
- package/dist/server/schema.d.ts +704 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +254 -1
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/databases.d.ts +10 -10
- 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 +32 -1
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +87 -4
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/macros.d.ts +83 -0
- package/dist/server/services/macros.d.ts.map +1 -0
- package/dist/server/services/macros.js +488 -0
- package/dist/server/services/macros.js.map +1 -0
- package/dist/server/services/publications.d.ts +2 -1
- package/dist/server/services/publications.d.ts.map +1 -1
- package/dist/server/services/publications.js +43 -4
- package/dist/server/services/publications.js.map +1 -1
- package/dist/server/services/templates.d.ts +135 -0
- package/dist/server/services/templates.d.ts.map +1 -0
- package/dist/server/services/templates.js +897 -0
- package/dist/server/services/templates.js.map +1 -0
- package/dist/server/services/versions.d.ts +13 -1
- package/dist/server/services/versions.d.ts.map +1 -1
- package/dist/server/services/versions.js +3 -1
- package/dist/server/services/versions.js.map +1 -1
- package/migrations/0010_transfers.sql +154 -0
- package/migrations/0011_templates.sql +157 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +5 -5
- 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/NewSpaceDialog.svelte +77 -8
- package/src/client/components/PageEditor.svelte +80 -0
- package/src/client/components/PagePicker.svelte +264 -0
- package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
- package/src/client/components/SidebarSpaces.svelte +112 -1
- package/src/client/components/TemplatePicker.svelte +437 -0
- package/src/client/i18n.ts +941 -0
- package/src/client/index.ts +51 -0
- package/src/client/mock.ts +592 -0
- package/src/client/module.ts +36 -0
- package/src/client/pages/PageView.svelte +86 -0
- package/src/client/pages/SpacePage.svelte +20 -4
- package/src/client/pages/TransfersPage.svelte +570 -0
- package/src/client/permissions.ts +11 -0
- package/src/client/query.ts +40 -0
- package/src/client/transfers.ts +142 -0
- package/src/contract/models.ts +343 -0
- package/src/contract/permissions.ts +117 -0
- 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))
|
package/src/contract/models.ts
CHANGED
|
@@ -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
|
|