@kernhq/module-quire 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/dist/contract/models.d.ts +394 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +284 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts +14 -0
  6. package/dist/contract/permissions.d.ts.map +1 -1
  7. package/dist/contract/permissions.js +114 -0
  8. package/dist/contract/permissions.js.map +1 -1
  9. package/dist/contract/properties.d.ts +7 -7
  10. package/dist/contract/router.d.ts +1124 -96
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +343 -1
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +983 -1168
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +308 -0
  17. package/dist/server/_impl.js.map +1 -1
  18. package/dist/server/export/html.d.ts +67 -0
  19. package/dist/server/export/html.d.ts.map +1 -0
  20. package/dist/server/export/html.js +206 -0
  21. package/dist/server/export/html.js.map +1 -0
  22. package/dist/server/export/markdown.d.ts +51 -0
  23. package/dist/server/export/markdown.d.ts.map +1 -0
  24. package/dist/server/export/markdown.js +351 -0
  25. package/dist/server/export/markdown.js.map +1 -0
  26. package/dist/server/export/pdf.d.ts +20 -0
  27. package/dist/server/export/pdf.d.ts.map +1 -0
  28. package/dist/server/export/pdf.js +91 -0
  29. package/dist/server/export/pdf.js.map +1 -0
  30. package/dist/server/export/zip.d.ts +31 -0
  31. package/dist/server/export/zip.d.ts.map +1 -0
  32. package/dist/server/export/zip.js +158 -0
  33. package/dist/server/export/zip.js.map +1 -0
  34. package/dist/server/import/csv.d.ts +77 -0
  35. package/dist/server/import/csv.d.ts.map +1 -0
  36. package/dist/server/import/csv.js +263 -0
  37. package/dist/server/import/csv.js.map +1 -0
  38. package/dist/server/import/html.d.ts +52 -0
  39. package/dist/server/import/html.d.ts.map +1 -0
  40. package/dist/server/import/html.js +472 -0
  41. package/dist/server/import/html.js.map +1 -0
  42. package/dist/server/import/markdown.d.ts +63 -0
  43. package/dist/server/import/markdown.d.ts.map +1 -0
  44. package/dist/server/import/markdown.js +692 -0
  45. package/dist/server/import/markdown.js.map +1 -0
  46. package/dist/server/import/plan.d.ts +70 -0
  47. package/dist/server/import/plan.d.ts.map +1 -0
  48. package/dist/server/import/plan.js +761 -0
  49. package/dist/server/import/plan.js.map +1 -0
  50. package/dist/server/import/ydoc.d.ts +35 -0
  51. package/dist/server/import/ydoc.d.ts.map +1 -0
  52. package/dist/server/import/ydoc.js +91 -0
  53. package/dist/server/import/ydoc.js.map +1 -0
  54. package/dist/server/import/zip.d.ts +63 -0
  55. package/dist/server/import/zip.d.ts.map +1 -0
  56. package/dist/server/import/zip.js +308 -0
  57. package/dist/server/import/zip.js.map +1 -0
  58. package/dist/server/index.d.ts.map +1 -1
  59. package/dist/server/index.js +3 -1
  60. package/dist/server/index.js.map +1 -1
  61. package/dist/server/render.d.ts +158 -0
  62. package/dist/server/render.d.ts.map +1 -1
  63. package/dist/server/render.js +240 -0
  64. package/dist/server/render.js.map +1 -1
  65. package/dist/server/schema.d.ts +704 -1
  66. package/dist/server/schema.d.ts.map +1 -1
  67. package/dist/server/schema.js +254 -1
  68. package/dist/server/schema.js.map +1 -1
  69. package/dist/server/services/databases.d.ts +10 -10
  70. package/dist/server/services/export.d.ts +176 -0
  71. package/dist/server/services/export.d.ts.map +1 -0
  72. package/dist/server/services/export.js +822 -0
  73. package/dist/server/services/export.js.map +1 -0
  74. package/dist/server/services/import.d.ts +109 -0
  75. package/dist/server/services/import.d.ts.map +1 -0
  76. package/dist/server/services/import.js +570 -0
  77. package/dist/server/services/import.js.map +1 -0
  78. package/dist/server/services/index.d.ts +32 -1
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +87 -4
  81. package/dist/server/services/index.js.map +1 -1
  82. package/dist/server/services/macros.d.ts +83 -0
  83. package/dist/server/services/macros.d.ts.map +1 -0
  84. package/dist/server/services/macros.js +488 -0
  85. package/dist/server/services/macros.js.map +1 -0
  86. package/dist/server/services/publications.d.ts +2 -1
  87. package/dist/server/services/publications.d.ts.map +1 -1
  88. package/dist/server/services/publications.js +43 -4
  89. package/dist/server/services/publications.js.map +1 -1
  90. package/dist/server/services/templates.d.ts +135 -0
  91. package/dist/server/services/templates.d.ts.map +1 -0
  92. package/dist/server/services/templates.js +897 -0
  93. package/dist/server/services/templates.js.map +1 -0
  94. package/dist/server/services/versions.d.ts +13 -1
  95. package/dist/server/services/versions.d.ts.map +1 -1
  96. package/dist/server/services/versions.js +3 -1
  97. package/dist/server/services/versions.js.map +1 -1
  98. package/migrations/0010_transfers.sql +154 -0
  99. package/migrations/0011_templates.sql +157 -0
  100. package/migrations/meta/_journal.json +14 -0
  101. package/package.json +5 -5
  102. package/src/client/components/ExportDialog.svelte +685 -0
  103. package/src/client/components/ImportDialog.svelte +702 -0
  104. package/src/client/components/ImportReport.svelte +310 -0
  105. package/src/client/components/NewSpaceDialog.svelte +77 -8
  106. package/src/client/components/PageEditor.svelte +80 -0
  107. package/src/client/components/PagePicker.svelte +264 -0
  108. package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
  109. package/src/client/components/SidebarSpaces.svelte +112 -1
  110. package/src/client/components/TemplatePicker.svelte +437 -0
  111. package/src/client/i18n.ts +941 -0
  112. package/src/client/index.ts +51 -0
  113. package/src/client/mock.ts +592 -0
  114. package/src/client/module.ts +36 -0
  115. package/src/client/pages/PageView.svelte +86 -0
  116. package/src/client/pages/SpacePage.svelte +20 -4
  117. package/src/client/pages/TransfersPage.svelte +570 -0
  118. package/src/client/permissions.ts +11 -0
  119. package/src/client/query.ts +40 -0
  120. package/src/client/transfers.ts +142 -0
  121. package/src/contract/models.ts +343 -0
  122. package/src/contract/permissions.ts +117 -0
  123. package/src/contract/router.ts +375 -0
@@ -0,0 +1,685 @@
1
+ <script lang="ts">
2
+ import { Button, Dialog, Icon, ProgressBar, relativeTime, toast, uid } from '@kernhq/ui'
3
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
4
+ import { tick, untrack } from 'svelte'
5
+ import type { ExportFormat, ExportJobDetail, ExportScope } from '../../contract/index.js'
6
+ import { getQuireApi } from '../api-instance.js'
7
+ import { t } from '../i18n.js'
8
+ import { quireKeys } from '../query.js'
9
+ import {
10
+ EXPORT_FORMATS,
11
+ formatDescription,
12
+ formatLabel,
13
+ isRunning,
14
+ messageOf,
15
+ progressRatio,
16
+ scopeLabel,
17
+ startDownload,
18
+ stateIcon,
19
+ stateLabel,
20
+ } from '../transfers.js'
21
+
22
+ /**
23
+ * Taking a page, a section or a whole space out of Quire as a file.
24
+ *
25
+ * **An export is a server job, so this dialog is a window onto one rather than the thing itself.**
26
+ * That is the single decision the rest of the file follows from. Pressing *Export* records a row and
27
+ * returns; the file is written by a worker minutes later; and closing the dialog, navigating away or
28
+ * reloading the browser has no effect on any of it. So the dialog has two modes and opens in
29
+ * whichever one is true:
30
+ *
31
+ * - **the form**, when nothing is running for this target, and
32
+ * - **a job**, when something is — found by asking `exports.list` on open rather than by remembering
33
+ * anything locally. Reload mid-export, reopen this dialog, and the same progress bar is there.
34
+ * A `$state` flag holding the job id would have been a job that only exists while a tab is open,
35
+ * which is exactly the lie this shape avoids.
36
+ *
37
+ * Two things about the file itself are stated on screen rather than assumed, because both surprise
38
+ * people: **an export contains only the pages the person asking may read** — a subtree export with a
39
+ * page withheld is a smaller file, not a refusal, and `counts.skipped` is the only way anybody finds
40
+ * out — and the artefact is **deleted after seven days**, so it is a download rather than an archive.
41
+ *
42
+ * Word is not offered. `ExportFormat` declares `docx` and the server refuses it at `exports.start`;
43
+ * a control whose only outcome is an error is worse than a sentence saying what to use instead, so
44
+ * the sentence is what this draws. See `services/export.ts` for why a *correct* `.docx` is not a
45
+ * matter of effort.
46
+ */
47
+ /**
48
+ * `spaceId` rather than the space itself, because the two entry points know different things.
49
+ *
50
+ * The page screen has a page's `spaceId` and the space's *key* from the URL and no name; the sidebar
51
+ * has the whole row. Passing an id and resolving the name here from the list the sidebar has already
52
+ * loaded is one lookup in one place, instead of a second `spaces.list` bolted onto `PageView` so that
53
+ * it can hand over a string this component was going to need anyway.
54
+ */
55
+ interface Props {
56
+ open?: boolean
57
+ workspaceId: string
58
+ /** the space the export lives in — the target of a `space` export, and where a page's tree is */
59
+ spaceId: string
60
+ /** the page the dialog was opened from; null when it was opened from the space menu */
61
+ page?: { id: string; title: string } | null
62
+ }
63
+ let { open = $bindable(false), workspaceId, spaceId, page = null }: Props = $props()
64
+
65
+ const api = getQuireApi()
66
+ const client = useQueryClient()
67
+
68
+ /**
69
+ * The space's name, for the sentence about what is being exported and for the file's name.
70
+ *
71
+ * `includeArchived: false` matches the sidebar's call exactly — same key, same arguments, so this
72
+ * costs nothing in the common case and cannot become a second query wearing the same name.
73
+ */
74
+ const spacesQuery = createQuery(() => ({
75
+ queryKey: quireKeys.spaces(workspaceId),
76
+ enabled: open && Boolean(workspaceId),
77
+ queryFn: () => api.spaces.list({ workspaceId, includeArchived: false }),
78
+ }))
79
+ const spaceName = $derived((spacesQuery.data ?? []).find((row) => row.id === spaceId)?.name ?? '')
80
+
81
+ /**
82
+ * Both entry points can be mounted at once — the page menu's dialog and the sidebar's — so the radio
83
+ * groups need names that cannot collide. Sharing one would make the two dialogs one control set:
84
+ * choosing a format in the sidebar would silently change the page dialog's.
85
+ */
86
+ const group = uid('quire-transfer')
87
+
88
+ // ------------------------------------------------------------------------------------------------
89
+ // What is already happening
90
+ // ------------------------------------------------------------------------------------------------
91
+
92
+ /**
93
+ * This person's own exports, newest first, and the reason this dialog asks for them at all.
94
+ *
95
+ * It is not a history — the transfers screen is that. It is the answer to "is one of these already
96
+ * running for the thing I am looking at", which is what makes a reload survivable and what stops
97
+ * somebody queueing the same space export four times because the first one had no visible trace.
98
+ */
99
+ const listQuery = createQuery(() => ({
100
+ queryKey: quireKeys.exports(workspaceId),
101
+ enabled: open && Boolean(workspaceId),
102
+ queryFn: () => api.exports.list({ workspaceId, limit: 20 }),
103
+ }))
104
+
105
+ let watching = $state<string | null>(null)
106
+ let scope = $state<ExportScope>('space')
107
+ let format = $state<ExportFormat>('markdown')
108
+ let error = $state<string | null>(null)
109
+
110
+ /**
111
+ * Which opening of the dialog the form belongs to, so it is filled once rather than on every render.
112
+ *
113
+ * The same `untrack` shape as `PublishDialog`: an effect that reads the fields it also writes is its
114
+ * own trigger. What genuinely selects this one is whether the dialog is open, which target it was
115
+ * opened for, and whether the list has arrived — never what it has already decided.
116
+ *
117
+ * `attached` is a plain `let` beside it, like `busy` below, and for the same reason: it records what
118
+ * this opening has already done and must not be a dependency. Without it, pressing **New export**
119
+ * would put you straight back on the finished job the list still holds.
120
+ */
121
+ let session = $state<string | null>(null)
122
+ let attached = false
123
+
124
+ $effect(() => {
125
+ const key = open ? `${page?.id ?? '-'}|${spaceId}` : null
126
+ if (untrack(() => session) !== key) {
127
+ session = key
128
+ attached = false
129
+ error = null
130
+ watching = null
131
+ scope = page ? 'page' : 'space'
132
+ format = 'markdown'
133
+ }
134
+ if (key === null || attached) return
135
+ const rows = listQuery.data
136
+ if (!rows) return
137
+ attached = true
138
+ /*
139
+ * Newest first from the server, so `find` is the most recent one about this page or this space.
140
+ * Any state, not only a running one: coming back to a finished export and being handed the file
141
+ * is the other half of "a job outlives the tab", and a failed one has to be seen at all.
142
+ */
143
+ const mine = rows.find((row) => (page !== null && row.targetId === page.id) || row.targetId === spaceId)
144
+ if (!mine) return
145
+ watching = mine.id
146
+ scope = mine.scope
147
+ format = mine.format
148
+ })
149
+
150
+ /**
151
+ * The job being watched.
152
+ *
153
+ * Polled while it is running **and** invalidated by realtime: the server announces a `change` on the
154
+ * `export` entity every time it writes the counters, and `quireKeys.exportJob` is under that entity's
155
+ * prefix, so on a healthy socket the bar moves without this timer. The timer is what makes it move
156
+ * anyway — in `dev:mock`, on a dropped socket, behind a proxy that eats websockets. Neither is
157
+ * redundant: one is fast and one is certain.
158
+ */
159
+ const jobQuery = createQuery(() => ({
160
+ queryKey: quireKeys.exportJob(workspaceId, watching ?? ''),
161
+ enabled: open && Boolean(workspaceId && watching),
162
+ queryFn: () => api.exports.get({ workspaceId, jobId: watching as string }),
163
+ refetchInterval: (query: { state: { data?: ExportJobDetail } }) =>
164
+ query.state.data && isRunning(query.state.data.state) ? 1500 : false,
165
+ }))
166
+ const job = $derived((jobQuery.data ?? null) as ExportJobDetail | null)
167
+ const ratio = $derived(job ? progressRatio(job.counts) : null)
168
+
169
+ // ------------------------------------------------------------------------------------------------
170
+ // What the form is offering
171
+ // ------------------------------------------------------------------------------------------------
172
+
173
+ /**
174
+ * The space's tree, for the two numbers the scope choices carry.
175
+ *
176
+ * The same key the sidebar already holds, with the same `includeArchived: false`, so opening this
177
+ * from a page costs no request — and a key that differed by one argument would be a second query
178
+ * wearing the same name. The count is what the *reader* can see, which is the same filter the export
179
+ * itself applies; `counts.skipped` afterwards is the authority, and this is the estimate before.
180
+ */
181
+ const treeQuery = createQuery(() => ({
182
+ queryKey: quireKeys.tree(workspaceId, spaceId),
183
+ enabled: open && Boolean(workspaceId && spaceId),
184
+ queryFn: () => api.pages.tree({ workspaceId, spaceId, includeArchived: false }),
185
+ }))
186
+
187
+ const subtreeCount = $derived.by((): number | null => {
188
+ const nodes = treeQuery.data
189
+ if (!nodes || !page) return null
190
+ const children = new Map<string, string[]>()
191
+ for (const node of nodes)
192
+ if (node.parentId) children.set(node.parentId, [...(children.get(node.parentId) ?? []), node.id])
193
+ let total = 1
194
+ let guard = 0
195
+ const stack = [page.id]
196
+ while (stack.length > 0 && guard++ < 5000) {
197
+ const id = stack.pop() as string
198
+ for (const child of children.get(id) ?? []) {
199
+ total++
200
+ stack.push(child)
201
+ }
202
+ }
203
+ return total
204
+ })
205
+ const spaceCount = $derived(treeQuery.data?.length ?? null)
206
+
207
+ interface Choice {
208
+ value: ExportScope
209
+ label: string
210
+ description: string
211
+ }
212
+ const scopes = $derived.by((): Choice[] => [
213
+ ...(page
214
+ ? [
215
+ {
216
+ value: 'page' as const,
217
+ label: t('export_scope_page'),
218
+ description: t('export_scope_page_desc', { title: page.title.trim() || t('untitled') }),
219
+ },
220
+ {
221
+ value: 'subtree' as const,
222
+ label: t('export_scope_subtree'),
223
+ description:
224
+ subtreeCount === null
225
+ ? t('export_scope_subtree_desc')
226
+ : t('export_count', { count: subtreeCount }),
227
+ },
228
+ ]
229
+ : []),
230
+ {
231
+ value: 'space' as const,
232
+ label: t('export_scope_space'),
233
+ description:
234
+ spaceCount === null ? t('export_scope_space_desc') : t('export_count', { count: spaceCount }),
235
+ },
236
+ ])
237
+
238
+ // ------------------------------------------------------------------------------------------------
239
+ // Doing it
240
+ // ------------------------------------------------------------------------------------------------
241
+
242
+ /* Set in the same tick as the click. `isPending` reaches the button one render later, so two quick
243
+ clicks on Export are one render apart and both get through — which here means two jobs, two
244
+ artefacts and two rows in everybody's list. Guarded rather than disabled, so the button somebody
245
+ is standing on is never taken away from under their focus. */
246
+ let busy = false
247
+
248
+ /**
249
+ * Where focus goes when the dialog swaps one half of itself for the other.
250
+ *
251
+ * Both transitions destroy the button that caused them — pressing **Export** replaces the form with
252
+ * a progress region, pressing **New export** replaces the progress region with the form — and the
253
+ * browser hands the focus of a removed element to `<body>` and leaves it there. A keyboard user then
254
+ * has to tab in from the top of the page to reach a dialog they are standing in. The same family as
255
+ * disabling a focused control, and `PublishDialog` documents the same fix for its confirmation.
256
+ *
257
+ * Going forward, focus lands on the status region rather than on a button, so a repeated Enter
258
+ * cannot press anything; coming back, it lands on the first scope option, which is where somebody
259
+ * about to start another export is going anyway.
260
+ */
261
+ let jobRegion = $state<HTMLElement | null>(null)
262
+ let scopeFieldset = $state<HTMLElement | null>(null)
263
+
264
+ /* Also a plain `let`, and deliberately not fed to `Button`'s `loading`: that prop disables the
265
+ button, and this is the button somebody's focus is sitting on — disabling it blurs the control
266
+ and the browser hands the focus to `<body>`. Guarding the handler stops the second click without
267
+ taking anybody's place on the page away. */
268
+ let downloading = false
269
+
270
+ async function start() {
271
+ if (busy) return
272
+ busy = true
273
+ error = null
274
+ try {
275
+ const created = await api.exports.start({
276
+ workspaceId,
277
+ scope,
278
+ targetId: scope === 'space' ? spaceId : (page?.id ?? spaceId),
279
+ format,
280
+ })
281
+ /*
282
+ * The row `start` answered with **is** the answer `get` would give, so it is put in the cache
283
+ * rather than waited for. Without this there is a gap — a round trip long — in which the job
284
+ * exists on the server and the dialog is still showing the form, with a live **Export** button
285
+ * on it: pressing it again in that gap queues a second job, a second artefact and a second row
286
+ * in everybody's list. The `busy` flag does not cover it, because it is cleared as soon as this
287
+ * function returns. Seeding closes the gap rather than papering over it, and it is honest —
288
+ * `exports.start` and `exports.get` return the same shape for the same row.
289
+ */
290
+ watching = created.id
291
+ client.setQueryData(quireKeys.exportJob(workspaceId, created.id), created)
292
+ await tick()
293
+ jobRegion?.focus()
294
+ // The list is what the next opening reads to find this job, so it has to know about it.
295
+ await listQuery.refetch()
296
+ } catch (err) {
297
+ error = messageOf(err)
298
+ } finally {
299
+ busy = false
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Ask for the link at the moment of the download, never before.
305
+ *
306
+ * `downloadUrl` is signed for fifteen minutes and the polling stops the moment the job is done, so
307
+ * the copy on screen goes stale in a dialog somebody leaves open over lunch. Re-reading the job is
308
+ * one request and cannot be stale — and it is the request the server checks the permission on, which
309
+ * is the whole point of the link being minted per fetch rather than stored on the row.
310
+ */
311
+ async function download() {
312
+ const id = watching
313
+ if (!id || downloading) return
314
+ downloading = true
315
+ error = null
316
+ try {
317
+ const fresh = await api.exports.get({ workspaceId, jobId: id })
318
+ if (!fresh.downloadUrl) {
319
+ error = t('export_no_link')
320
+ return
321
+ }
322
+ const stem = (page && fresh.scope !== 'space' ? page.title : spaceName).trim() || t('untitled')
323
+ startDownload(fresh.downloadUrl, `${stem}.${fresh.format === 'pdf' ? 'pdf' : 'zip'}`)
324
+ } catch (err) {
325
+ error = messageOf(err)
326
+ } finally {
327
+ downloading = false
328
+ }
329
+ }
330
+
331
+ async function copyError(text: string) {
332
+ try {
333
+ await navigator.clipboard.writeText(text)
334
+ toast.success(t('transfer_copied'))
335
+ } catch {
336
+ // A denied clipboard is not worth an error: the text is on screen to select.
337
+ toast.info(t('share_copy_manually'))
338
+ }
339
+ }
340
+
341
+ /** Back to the form, keeping the last scope and format — "again, but PDF" is the common second act. */
342
+ async function newExport() {
343
+ watching = null
344
+ error = null
345
+ await tick()
346
+ scopeFieldset?.querySelector('input')?.focus()
347
+ }
348
+
349
+ /**
350
+ * What is being exported, in words, or `''` while the spaces list is still on its way.
351
+ *
352
+ * Empty is a state the template branches on rather than papering over. Interpolating a blank into
353
+ * "The whole space · · Markdown" is the sort of half-drawn sentence that looks like a bug and is
354
+ * one; a second message with no name in it is the honest version of the same line, and it is on
355
+ * screen for the fraction of a second before the cached list arrives.
356
+ */
357
+ const targetName = $derived(job && job.scope === 'space' ? spaceName : (page?.title.trim() ?? spaceName))
358
+ </script>
359
+
360
+ <Dialog bind:open title={t('export_title')} size="md">
361
+ {#if job}
362
+ <!--
363
+ `status`, not `alert`: this region rewrites itself every second and a half while a job runs, and
364
+ an assertive live region would interrupt a screen reader on every one of those. `aria-busy`
365
+ says the same thing about the whole block that a spinner says visually.
366
+ -->
367
+ <!-- `tabindex="-1"` so it can be given focus deliberately when the form is replaced by it; it is
368
+ never in the tab order itself. -->
369
+ <section
370
+ class="job"
371
+ role="status"
372
+ tabindex="-1"
373
+ bind:this={jobRegion}
374
+ aria-busy={isRunning(job.state)}
375
+ >
376
+ <p class="job-state">
377
+ <span class="job-icon" class:spin={isRunning(job.state)} class:bad={job.state === 'failed'}>
378
+ <Icon name={stateIcon(job.state)} size={17} />
379
+ </span>
380
+ {stateLabel(job.state)}
381
+ </p>
382
+ <p class="job-what">
383
+ {targetName
384
+ ? t('export_of', {
385
+ scope: scopeLabel(job.scope),
386
+ name: targetName,
387
+ format: formatLabel(job.format),
388
+ })
389
+ : t('export_of_unnamed', { scope: scopeLabel(job.scope), format: formatLabel(job.format) })}
390
+ </p>
391
+
392
+ {#if isRunning(job.state)}
393
+ {#if ratio === null}
394
+ <p class="note">{t('export_counting')}</p>
395
+ {:else}
396
+ <ProgressBar value={ratio * 100} label={t('export_title')} />
397
+ <!--
398
+ The numbers go in as numbers. `t()` puts every interpolated number through
399
+ `Intl.NumberFormat` for the interface locale, so "۳ of ۱۲" on a Persian screen comes for
400
+ free — pre-formatting them into strings here is what leaves Latin digits in the one
401
+ sentence on the page that is nothing but digits.
402
+ -->
403
+ <p class="note">
404
+ {t('export_progress', {
405
+ done: job.counts.done + job.counts.skipped + job.counts.failed,
406
+ total: job.counts.total,
407
+ })}
408
+ </p>
409
+ {/if}
410
+ {/if}
411
+
412
+ {#if job.state === 'done'}
413
+ {#if job.counts.done === 0}
414
+ <!--
415
+ An artefact with nothing in it is a success by every counter and a broken file to whoever
416
+ opens it. It happens for one reason — every page in scope was withheld — so it says that
417
+ rather than leaving somebody to work it out from a zip with no folders in it.
418
+ -->
419
+ <p class="warn">{t('export_empty')}</p>
420
+ {:else if job.counts.skipped > 0}
421
+ <p class="warn">{t('export_skipped', { count: job.counts.skipped })}</p>
422
+ {/if}
423
+ <p class="note">{t('export_kept')}</p>
424
+ {/if}
425
+
426
+ {#if job.state === 'failed' && job.error}
427
+ <!--
428
+ The reason, in the words of whatever refused — a Gotenberg that is not running, a page that
429
+ would not render. It is diagnostic rather than kind, so it is drawn as what it is: mono,
430
+ selectable, and with the one control that makes it useful to somebody who has to paste it
431
+ into a message to an administrator.
432
+ -->
433
+ <!--
434
+ `dir="auto"`: `error` is whatever refused, in its own words, and that is English on every
435
+ instance whatever the interface language is. Inheriting an RTL paragraph direction lays a
436
+ Latin sentence out right to left and breaks it at its punctuation — a URL in a Gotenberg
437
+ refusal comes apart at the colon. The first strong character decides instead.
438
+ -->
439
+ <div class="fail">
440
+ <p class="fail-text" dir="auto">{job.error}</p>
441
+ <Button size="xs" variant="ghost" icon="copy" onclick={() => void copyError(job.error ?? '')}>
442
+ {t('transfer_copy')}
443
+ </Button>
444
+ </div>
445
+ {/if}
446
+
447
+ <p class="when">{t('export_started', { when: relativeTime(job.createdAt) })}</p>
448
+ </section>
449
+ {:else}
450
+ <fieldset class="choices" bind:this={scopeFieldset}>
451
+ <legend>{t('export_scope')}</legend>
452
+ {#each scopes as choice (choice.value)}
453
+ <label class="choice" class:on={scope === choice.value}>
454
+ <input
455
+ type="radio"
456
+ name={`${group}-scope`}
457
+ value={choice.value}
458
+ checked={scope === choice.value}
459
+ onchange={() => (scope = choice.value)}
460
+ />
461
+ <span class="choice-body">
462
+ <span class="choice-title">{choice.label}</span>
463
+ <span class="choice-desc">{choice.description}</span>
464
+ </span>
465
+ </label>
466
+ {/each}
467
+ </fieldset>
468
+
469
+ <fieldset class="choices">
470
+ <legend>{t('export_format')}</legend>
471
+ {#each EXPORT_FORMATS as option (option)}
472
+ <label class="choice" class:on={format === option}>
473
+ <input
474
+ type="radio"
475
+ name={`${group}-format`}
476
+ value={option}
477
+ checked={format === option}
478
+ onchange={() => (format = option)}
479
+ />
480
+ <span class="choice-body">
481
+ <span class="choice-title">{formatLabel(option)}</span>
482
+ <span class="choice-desc">{formatDescription(option)}</span>
483
+ </span>
484
+ </label>
485
+ {/each}
486
+ </fieldset>
487
+
488
+ <p class="note">{t('export_no_docx')}</p>
489
+ <!--
490
+ Said before the button, not after the file arrives. "Only the pages you can read" is the one
491
+ thing about an export that people are wrong about, and being wrong about it looks like a
492
+ complete handbook until somebody notices three sections missing.
493
+ -->
494
+ <p class="note">{t('export_only_readable')}</p>
495
+ {/if}
496
+
497
+ {#if error}<p class="error" role="alert">{error}</p>{/if}
498
+
499
+ {#snippet footer()}
500
+ <div class="foot">
501
+ {#if job}
502
+ <Button variant="ghost" size="sm" onclick={newExport}>{t('export_new')}</Button>
503
+ <span class="spacer"></span>
504
+ <Button variant="secondary" onclick={() => (open = false)}>{t('transfer_close')}</Button>
505
+ {#if job.state === 'done' && job.counts.done > 0}
506
+ <Button icon="download" onclick={() => void download()}>{t('export_download')}</Button>
507
+ {/if}
508
+ {:else}
509
+ <span class="spacer"></span>
510
+ <Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
511
+ <Button icon="download" onclick={() => void start()}>{t('export_start')}</Button>
512
+ {/if}
513
+ </div>
514
+ {/snippet}
515
+ </Dialog>
516
+
517
+ <style>
518
+ /*
519
+ * A choice is a whole card, so the target is the sentence and not a 13px disc. `align-items: start`
520
+ * keeps the disc against the first line of a description that wraps to three.
521
+ */
522
+ .choices {
523
+ display: flex;
524
+ flex-direction: column;
525
+ gap: 6px;
526
+ margin: 0 0 16px;
527
+ padding: 0;
528
+ border: 0;
529
+ }
530
+ .choices legend {
531
+ padding: 0 0 8px;
532
+ font-size: 12.5px;
533
+ font-weight: 500;
534
+ color: var(--kern-ink-600);
535
+ }
536
+ .choice {
537
+ display: flex;
538
+ align-items: start;
539
+ gap: 10px;
540
+ padding: 10px 12px;
541
+ border: 1px solid var(--kern-border);
542
+ border-radius: var(--kern-r-lg);
543
+ cursor: pointer;
544
+ }
545
+ .choice:hover {
546
+ border-color: var(--kern-border-hover);
547
+ background: var(--kern-surface-hover);
548
+ }
549
+ .choice.on {
550
+ border-color: var(--kern-accent);
551
+ background: var(--kern-accent-tint);
552
+ }
553
+ /* The whole card is the label, so the ring belongs to the card rather than to the disc inside it. */
554
+ .choice:focus-within {
555
+ outline: 2px solid var(--kern-ring);
556
+ outline-offset: 1px;
557
+ }
558
+ .choice input {
559
+ margin: 2px 0 0;
560
+ accent-color: var(--kern-accent);
561
+ /* the disc is the smallest thing here and still has to be visible against the tinted card */
562
+ width: 15px;
563
+ height: 15px;
564
+ flex: none;
565
+ }
566
+ .choice-body {
567
+ display: flex;
568
+ flex-direction: column;
569
+ gap: 2px;
570
+ min-width: 0;
571
+ }
572
+ .choice-title {
573
+ font-size: 13.5px;
574
+ font-weight: 500;
575
+ color: var(--kern-ink-900);
576
+ }
577
+ .choice-desc {
578
+ font-size: 12.5px;
579
+ line-height: 1.5;
580
+ color: var(--kern-ink-600);
581
+ text-wrap: pretty;
582
+ }
583
+
584
+ .job {
585
+ display: flex;
586
+ flex-direction: column;
587
+ gap: 10px;
588
+ }
589
+ .job-state {
590
+ display: flex;
591
+ align-items: center;
592
+ gap: 8px;
593
+ margin: 0;
594
+ font-size: 15px;
595
+ font-weight: 600;
596
+ color: var(--kern-ink-900);
597
+ }
598
+ .job-icon {
599
+ display: inline-flex;
600
+ color: var(--kern-ink-500);
601
+ }
602
+ .job-icon.bad {
603
+ color: var(--kern-danger);
604
+ }
605
+ .job-icon.spin {
606
+ animation: quire-spin 900ms linear infinite;
607
+ }
608
+ @media (prefers-reduced-motion: reduce) {
609
+ .job-icon.spin {
610
+ animation: none;
611
+ }
612
+ }
613
+ @keyframes quire-spin {
614
+ to {
615
+ transform: rotate(360deg);
616
+ }
617
+ }
618
+ .job-what {
619
+ margin: -6px 0 0;
620
+ font-size: 13px;
621
+ color: var(--kern-ink-700);
622
+ text-wrap: pretty;
623
+ }
624
+
625
+ .note {
626
+ margin: 0;
627
+ font-size: 12.5px;
628
+ line-height: 1.55;
629
+ /* muted with a colour: `opacity` fades this against the dialog until nobody can read it */
630
+ color: var(--kern-ink-550);
631
+ text-wrap: pretty;
632
+ }
633
+ .warn {
634
+ margin: 0;
635
+ padding: 9px 11px;
636
+ border-radius: var(--kern-r-lg);
637
+ background: var(--kern-warning-tint);
638
+ color: var(--kern-ink-800);
639
+ font-size: 12.5px;
640
+ line-height: 1.55;
641
+ text-wrap: pretty;
642
+ }
643
+ .when {
644
+ margin: 0;
645
+ font-size: 12px;
646
+ color: var(--kern-ink-450);
647
+ }
648
+
649
+ .fail {
650
+ display: flex;
651
+ align-items: start;
652
+ gap: 8px;
653
+ padding: 10px 11px;
654
+ border-radius: var(--kern-r-lg);
655
+ background: var(--kern-danger-tint);
656
+ }
657
+ .fail-text {
658
+ flex: 1;
659
+ min-width: 0;
660
+ margin: 0;
661
+ font-family: var(--kern-font-mono);
662
+ font-size: 12px;
663
+ line-height: 1.5;
664
+ color: var(--kern-ink-800);
665
+ overflow-wrap: anywhere;
666
+ user-select: text;
667
+ }
668
+
669
+ .error {
670
+ margin: 12px 0 0;
671
+ font-size: 13px;
672
+ color: var(--kern-danger);
673
+ }
674
+
675
+ .foot {
676
+ display: flex;
677
+ align-items: center;
678
+ gap: 8px;
679
+ flex-wrap: wrap;
680
+ width: 100%;
681
+ }
682
+ .spacer {
683
+ flex: 1;
684
+ }
685
+ </style>