@kernhq/module-quire 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +535 -8
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +133 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +519 -8
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +128 -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 +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 +26 -1
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +60 -1
- package/dist/server/services/index.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 +147 -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>
|