@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,702 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Dialog,
|
|
5
|
+
formatBytes,
|
|
6
|
+
Icon,
|
|
7
|
+
navigation,
|
|
8
|
+
ProgressBar,
|
|
9
|
+
relativeTime,
|
|
10
|
+
toast,
|
|
11
|
+
uid,
|
|
12
|
+
uploadFile,
|
|
13
|
+
} from '@kernhq/ui'
|
|
14
|
+
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
15
|
+
import { tick, untrack } from 'svelte'
|
|
16
|
+
import type { ImportJob, ImportSource } from '../../contract/index.js'
|
|
17
|
+
import { getQuireApi } from '../api-instance.js'
|
|
18
|
+
import { t } from '../i18n.js'
|
|
19
|
+
import { quireKeys } from '../query.js'
|
|
20
|
+
import {
|
|
21
|
+
IMPORT_SOURCES,
|
|
22
|
+
isRunning,
|
|
23
|
+
messageOf,
|
|
24
|
+
sourceDescription,
|
|
25
|
+
sourceLabel,
|
|
26
|
+
stateIcon,
|
|
27
|
+
stateLabel,
|
|
28
|
+
} from '../transfers.js'
|
|
29
|
+
import ImportReport from './ImportReport.svelte'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Bringing a Notion, Confluence or Markdown export into one space.
|
|
33
|
+
*
|
|
34
|
+
* **This is the one thing in Quire that writes hundreds of pages in a single act**, and the whole
|
|
35
|
+
* shape of the dialog follows from that rather than from the upload being fiddly:
|
|
36
|
+
*
|
|
37
|
+
* 1. **The consequence is stated above the button that causes it, and it names the space.** Not in
|
|
38
|
+
* a tooltip and not after the fact: "adds every page in this archive to *Handbook*", "nothing
|
|
39
|
+
* already in Handbook is changed", "there is no single way back". The permission is declared
|
|
40
|
+
* `dangerous` on the server for the same reason, and a dangerous permission whose screen says
|
|
41
|
+
* nothing is a permission flag doing no work.
|
|
42
|
+
* 2. **The report is the outcome, not the counts.** A real export has files that will not map, so
|
|
43
|
+
* the job answers with one row per file and the dialog draws them. An import that finished with
|
|
44
|
+
* forty silent drops looks identical to one that finished cleanly, right up until somebody goes
|
|
45
|
+
* looking for a page.
|
|
46
|
+
* 3. **An import is a server job**, so this attaches to a running one on open rather than
|
|
47
|
+
* remembering anything locally — reload mid-import, reopen, and the progress is still here. The
|
|
48
|
+
* same shape as `ExportDialog`, for the same reason.
|
|
49
|
+
*
|
|
50
|
+
* The upload is the shell's one uploader (`uploadFile` in `@kernhq/ui`): ask core for a ticket, PUT
|
|
51
|
+
* the bytes straight to storage, tell core the file is ready. Skipping the third step leaves the
|
|
52
|
+
* file `pending`, and `imports.start` refuses a file that is not `ready` — which is the check
|
|
53
|
+
* catching a mistake this dialog cannot make, rather than a reason to do the upload by hand.
|
|
54
|
+
*/
|
|
55
|
+
interface Props {
|
|
56
|
+
open?: boolean
|
|
57
|
+
workspaceId: string
|
|
58
|
+
/** the space being written into. An import always targets exactly one, and it is named on screen. */
|
|
59
|
+
space: { id: string; key: string; name: string }
|
|
60
|
+
}
|
|
61
|
+
let { open = $bindable(false), workspaceId, space }: Props = $props()
|
|
62
|
+
|
|
63
|
+
const api = getQuireApi()
|
|
64
|
+
const client = useQueryClient()
|
|
65
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
66
|
+
|
|
67
|
+
/** Unique per instance: the sidebar's dialog and any other can be mounted at the same time. */
|
|
68
|
+
const group = uid('quire-import')
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Mirrors `MAX_ARCHIVE_BYTES` in `services/import.ts`, and is checked here **as well as** there.
|
|
72
|
+
*
|
|
73
|
+
* Not instead of. The server's copy is the one that matters and the one that cannot be bypassed;
|
|
74
|
+
* this one exists so that a 400 MB archive is refused before somebody watches it upload for six
|
|
75
|
+
* minutes. A limit checked only on the client is a lie; a limit checked only on the server is six
|
|
76
|
+
* wasted minutes and a failure with no obvious cause.
|
|
77
|
+
*/
|
|
78
|
+
const MAX_BYTES = 256 * 1024 * 1024
|
|
79
|
+
|
|
80
|
+
// ------------------------------------------------------------------------------------------------
|
|
81
|
+
// What is already happening
|
|
82
|
+
// ------------------------------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
const listQuery = createQuery(() => ({
|
|
85
|
+
queryKey: quireKeys.imports(workspaceId),
|
|
86
|
+
enabled: open && Boolean(workspaceId),
|
|
87
|
+
queryFn: () => api.imports.list({ workspaceId, limit: 20 }),
|
|
88
|
+
}))
|
|
89
|
+
|
|
90
|
+
let watching = $state<string | null>(null)
|
|
91
|
+
let source = $state<ImportSource>('notion')
|
|
92
|
+
let file = $state<File | null>(null)
|
|
93
|
+
let error = $state<string | null>(null)
|
|
94
|
+
/**
|
|
95
|
+
* Two variables rather than one, and the difference is a bug that was written here first.
|
|
96
|
+
*
|
|
97
|
+
* `sending` is whether an upload is happening; `uploaded` is how far along it is, and it is **null
|
|
98
|
+
* whenever the browser is not reporting progress** — a same-origin PUT of a small file often reports
|
|
99
|
+
* nothing at all. Deriving "is it sending" from "do we have a ratio" made the progress region vanish
|
|
100
|
+
* mid-upload on exactly those transfers, which reads as a dialog that gave up.
|
|
101
|
+
*/
|
|
102
|
+
let sending = $state(false)
|
|
103
|
+
let uploaded = $state<number | null>(null)
|
|
104
|
+
let dragging = $state(false)
|
|
105
|
+
|
|
106
|
+
/** Which opening this form belongs to; `attached` records what that opening already looked for. */
|
|
107
|
+
let session = $state<string | null>(null)
|
|
108
|
+
let attached = false
|
|
109
|
+
|
|
110
|
+
$effect(() => {
|
|
111
|
+
const key = open ? space.id : null
|
|
112
|
+
if (untrack(() => session) !== key) {
|
|
113
|
+
session = key
|
|
114
|
+
attached = false
|
|
115
|
+
error = null
|
|
116
|
+
watching = null
|
|
117
|
+
file = null
|
|
118
|
+
sending = false
|
|
119
|
+
uploaded = null
|
|
120
|
+
source = 'notion'
|
|
121
|
+
}
|
|
122
|
+
if (key === null || attached) return
|
|
123
|
+
const rows = listQuery.data
|
|
124
|
+
if (!rows) return
|
|
125
|
+
attached = true
|
|
126
|
+
// Newest first from the server, so this is the most recent import into this space — running or
|
|
127
|
+
// finished. Coming back to a report is the other half of "a job outlives the tab".
|
|
128
|
+
const mine = rows.find((row) => row.targetId === space.id)
|
|
129
|
+
if (!mine) return
|
|
130
|
+
watching = mine.id
|
|
131
|
+
source = mine.source
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Polled while running, and invalidated by realtime beside it.
|
|
136
|
+
*
|
|
137
|
+
* The server announces a `change` on the `import` entity as the job moves, and this key sits under
|
|
138
|
+
* that entity's prefix — so on a healthy socket the report appears the moment it is written. The
|
|
139
|
+
* timer is what makes it appear anyway in `dev:mock` or behind a proxy that eats websockets.
|
|
140
|
+
*/
|
|
141
|
+
const jobQuery = createQuery(() => ({
|
|
142
|
+
queryKey: quireKeys.importJob(workspaceId, watching ?? ''),
|
|
143
|
+
enabled: open && Boolean(workspaceId && watching),
|
|
144
|
+
queryFn: () => api.imports.get({ workspaceId, jobId: watching as string }),
|
|
145
|
+
refetchInterval: (query: { state: { data?: ImportJob } }) =>
|
|
146
|
+
query.state.data && isRunning(query.state.data.state) ? 1500 : false,
|
|
147
|
+
}))
|
|
148
|
+
const job = $derived((jobQuery.data ?? null) as ImportJob | null)
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A finished import has put pages into the space, and every tree, list and trash view of it is now
|
|
152
|
+
* wrong. Done once, when the job first reports `done`, rather than on every poll.
|
|
153
|
+
*/
|
|
154
|
+
let refreshedFor: string | null = null
|
|
155
|
+
$effect(() => {
|
|
156
|
+
const finished = job && job.state === 'done' ? job.id : null
|
|
157
|
+
if (!finished || refreshedFor === finished) return
|
|
158
|
+
refreshedFor = finished
|
|
159
|
+
void client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, space.id) })
|
|
160
|
+
void client.invalidateQueries({ queryKey: quireKeys.spaces(workspaceId) })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// ------------------------------------------------------------------------------------------------
|
|
164
|
+
// Choosing a file
|
|
165
|
+
// ------------------------------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
const looksLikeZip = (chosen: File) =>
|
|
168
|
+
/\.zip$/i.test(chosen.name) ||
|
|
169
|
+
chosen.type === 'application/zip' ||
|
|
170
|
+
chosen.type === 'application/x-zip-compressed'
|
|
171
|
+
|
|
172
|
+
function choose(chosen: File | null | undefined) {
|
|
173
|
+
if (!chosen) return
|
|
174
|
+
error = null
|
|
175
|
+
if (!looksLikeZip(chosen)) {
|
|
176
|
+
file = null
|
|
177
|
+
error = t('import_wrong_type')
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
if (chosen.size > MAX_BYTES) {
|
|
181
|
+
file = null
|
|
182
|
+
error = t('import_too_big', { size: formatBytes(chosen.size), max: formatBytes(MAX_BYTES) })
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
file = chosen
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function onDrop(event: DragEvent) {
|
|
189
|
+
event.preventDefault()
|
|
190
|
+
dragging = false
|
|
191
|
+
choose(event.dataTransfer?.files?.[0])
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ------------------------------------------------------------------------------------------------
|
|
195
|
+
// Doing it
|
|
196
|
+
// ------------------------------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/* Set in the same tick as the click. `isPending` reaches the button one render later, so a
|
|
199
|
+
double-click is two uploads and two imports — and an import run twice is every page twice. */
|
|
200
|
+
let busy = false
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Where focus goes when the dialog swaps one half of itself for the other.
|
|
204
|
+
*
|
|
205
|
+
* Both transitions destroy the button that caused them, and the browser hands the focus of a removed
|
|
206
|
+
* element to `<body>` and leaves it there — so a keyboard user who presses **Import** has to tab in
|
|
207
|
+
* from the top of the page to reach the dialog they are standing in. Forward, focus lands on the
|
|
208
|
+
* status region rather than a button, so a repeated Enter cannot press anything; back, it lands on
|
|
209
|
+
* the first source option. Same fix `ExportDialog` and `PublishDialog` carry, for the same reason.
|
|
210
|
+
*/
|
|
211
|
+
let jobRegion = $state<HTMLElement | null>(null)
|
|
212
|
+
let sourceFieldset = $state<HTMLElement | null>(null)
|
|
213
|
+
|
|
214
|
+
async function start() {
|
|
215
|
+
const chosen = file
|
|
216
|
+
if (busy || !chosen) return
|
|
217
|
+
busy = true
|
|
218
|
+
error = null
|
|
219
|
+
sending = true
|
|
220
|
+
uploaded = null
|
|
221
|
+
try {
|
|
222
|
+
const object = await uploadFile({
|
|
223
|
+
workspaceId,
|
|
224
|
+
file: chosen,
|
|
225
|
+
name: chosen.name,
|
|
226
|
+
mimeType: 'application/zip',
|
|
227
|
+
attachedTo: { module: 'quire', type: 'import', id: space.id },
|
|
228
|
+
// `ratio` is null whenever the browser is not reporting progress, and then no bar is drawn:
|
|
229
|
+
// a bar pinned at 0% for the whole upload is a worse answer than the sentence beside it.
|
|
230
|
+
onProgress: ({ ratio }) => {
|
|
231
|
+
uploaded = ratio
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
const created = await api.imports.start({
|
|
235
|
+
workspaceId,
|
|
236
|
+
spaceId: space.id,
|
|
237
|
+
source,
|
|
238
|
+
fileId: object.id,
|
|
239
|
+
})
|
|
240
|
+
/*
|
|
241
|
+
* The row `start` answered with **is** the answer `get` would give, so it is put in the cache
|
|
242
|
+
* rather than waited for. Without this there is a round trip in which the job exists on the
|
|
243
|
+
* server and the dialog is still showing the form with a live **Import** button on it — and an
|
|
244
|
+
* import run twice is every page in the archive written twice. `busy` does not cover it: it is
|
|
245
|
+
* cleared as soon as this function returns.
|
|
246
|
+
*/
|
|
247
|
+
watching = created.id
|
|
248
|
+
client.setQueryData(quireKeys.importJob(workspaceId, created.id), created)
|
|
249
|
+
await tick()
|
|
250
|
+
jobRegion?.focus()
|
|
251
|
+
await listQuery.refetch()
|
|
252
|
+
} catch (err) {
|
|
253
|
+
error = messageOf(err)
|
|
254
|
+
} finally {
|
|
255
|
+
sending = false
|
|
256
|
+
uploaded = null
|
|
257
|
+
busy = false
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function copy(text: string) {
|
|
262
|
+
try {
|
|
263
|
+
await navigator.clipboard.writeText(text)
|
|
264
|
+
toast.success(t('transfer_copied'))
|
|
265
|
+
} catch {
|
|
266
|
+
toast.info(t('share_copy_manually'))
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Back to the form. The archive is not kept — a second import needs a second, deliberate choice. */
|
|
271
|
+
async function again() {
|
|
272
|
+
watching = null
|
|
273
|
+
file = null
|
|
274
|
+
error = null
|
|
275
|
+
await tick()
|
|
276
|
+
sourceFieldset?.querySelector('input')?.focus()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const fileFieldId = $derived(`${group}-file`)
|
|
280
|
+
const spaceHref = $derived(`/${workspaceSlug}/quire/${encodeURIComponent(space.key)}`)
|
|
281
|
+
const pageHref = (pageId: string) =>
|
|
282
|
+
`/${workspaceSlug}/quire/${encodeURIComponent(space.key)}/${encodeURIComponent(pageId)}`
|
|
283
|
+
</script>
|
|
284
|
+
|
|
285
|
+
<Dialog bind:open title={t('import_title')} size="lg">
|
|
286
|
+
{#if job}
|
|
287
|
+
<!-- `tabindex="-1"` so it can be given focus deliberately when the form is replaced by it; it is
|
|
288
|
+
never in the tab order itself. -->
|
|
289
|
+
<section
|
|
290
|
+
class="job"
|
|
291
|
+
role="status"
|
|
292
|
+
tabindex="-1"
|
|
293
|
+
bind:this={jobRegion}
|
|
294
|
+
aria-busy={isRunning(job.state)}
|
|
295
|
+
>
|
|
296
|
+
<p class="job-state">
|
|
297
|
+
<span class="job-icon" class:spin={isRunning(job.state)} class:bad={job.state === 'failed'}>
|
|
298
|
+
<Icon name={stateIcon(job.state)} size={17} />
|
|
299
|
+
</span>
|
|
300
|
+
{stateLabel(job.state)}
|
|
301
|
+
</p>
|
|
302
|
+
<p class="job-what">
|
|
303
|
+
{t('import_of', { source: sourceLabel(job.source), name: space.name })}
|
|
304
|
+
</p>
|
|
305
|
+
|
|
306
|
+
{#if isRunning(job.state)}
|
|
307
|
+
<!--
|
|
308
|
+
No bar. An import reads the whole archive before it writes anything, so there is no total
|
|
309
|
+
to measure against until the moment it is finished — and a bar with nothing behind it is
|
|
310
|
+
worse than a sentence, because a bar is a promise about how long this will take.
|
|
311
|
+
-->
|
|
312
|
+
<p class="note">{t('import_reading')}</p>
|
|
313
|
+
{/if}
|
|
314
|
+
|
|
315
|
+
{#if job.state === 'failed'}
|
|
316
|
+
<!--
|
|
317
|
+
Said first and said plainly. An import is written in one transaction, so a failure leaves
|
|
318
|
+
the space exactly as it was — and the thing somebody wants to know before anything else is
|
|
319
|
+
whether they now have half an archive in their handbook.
|
|
320
|
+
-->
|
|
321
|
+
<p class="warn">{t('import_failed_nothing')}</p>
|
|
322
|
+
{#if job.error}
|
|
323
|
+
<!-- `dir="auto"` for the same reason as the report's reasons: the server writes this in
|
|
324
|
+
English, and an English sentence inheriting an RTL direction breaks at its punctuation. -->
|
|
325
|
+
<div class="fail">
|
|
326
|
+
<p class="fail-text" dir="auto">{job.error}</p>
|
|
327
|
+
<Button size="xs" variant="ghost" icon="copy" onclick={() => void copy(job.error ?? '')}>
|
|
328
|
+
{t('transfer_copy')}
|
|
329
|
+
</Button>
|
|
330
|
+
</div>
|
|
331
|
+
{/if}
|
|
332
|
+
{/if}
|
|
333
|
+
|
|
334
|
+
{#if job.state === 'done' || job.report.length > 0}
|
|
335
|
+
<ImportReport report={job.report} counts={job.counts} link={pageHref} />
|
|
336
|
+
{/if}
|
|
337
|
+
|
|
338
|
+
<p class="when">{t('import_started', { when: relativeTime(job.createdAt) })}</p>
|
|
339
|
+
</section>
|
|
340
|
+
{:else}
|
|
341
|
+
<fieldset class="choices" bind:this={sourceFieldset}>
|
|
342
|
+
<legend>{t('import_source')}</legend>
|
|
343
|
+
{#each IMPORT_SOURCES as option (option)}
|
|
344
|
+
<label class="choice" class:on={source === option}>
|
|
345
|
+
<input
|
|
346
|
+
type="radio"
|
|
347
|
+
name={`${group}-source`}
|
|
348
|
+
value={option}
|
|
349
|
+
checked={source === option}
|
|
350
|
+
onchange={() => (source = option)}
|
|
351
|
+
/>
|
|
352
|
+
<span class="choice-body">
|
|
353
|
+
<span class="choice-title">{sourceLabel(option)}</span>
|
|
354
|
+
<span class="choice-desc">{sourceDescription(option)}</span>
|
|
355
|
+
</span>
|
|
356
|
+
</label>
|
|
357
|
+
{/each}
|
|
358
|
+
</fieldset>
|
|
359
|
+
|
|
360
|
+
<!--
|
|
361
|
+
The input stays in the tree and stays focusable — it is the control, and the label is only its
|
|
362
|
+
visible surface. Hiding it with `display: none` would take the whole thing away from a keyboard
|
|
363
|
+
and leave a label pointing at nothing.
|
|
364
|
+
-->
|
|
365
|
+
<div class="drop" class:on={dragging}>
|
|
366
|
+
<input
|
|
367
|
+
id={fileFieldId}
|
|
368
|
+
class="file"
|
|
369
|
+
type="file"
|
|
370
|
+
accept=".zip,application/zip"
|
|
371
|
+
onchange={(event: Event) => choose((event.currentTarget as HTMLInputElement).files?.[0])}
|
|
372
|
+
/>
|
|
373
|
+
<label
|
|
374
|
+
for={fileFieldId}
|
|
375
|
+
ondragover={(event: DragEvent) => {
|
|
376
|
+
event.preventDefault()
|
|
377
|
+
dragging = true
|
|
378
|
+
}}
|
|
379
|
+
ondragleave={() => (dragging = false)}
|
|
380
|
+
ondrop={onDrop}
|
|
381
|
+
>
|
|
382
|
+
<Icon name="upload" size={20} />
|
|
383
|
+
<span class="drop-title">{file ? file.name : t('import_choose')}</span>
|
|
384
|
+
<span class="drop-hint">
|
|
385
|
+
{file ? formatBytes(file.size) : t('import_file_hint', { max: formatBytes(MAX_BYTES) })}
|
|
386
|
+
</span>
|
|
387
|
+
</label>
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
<!--
|
|
391
|
+
What is about to happen, above the button that does it, in the words somebody would use
|
|
392
|
+
afterwards — and naming the space every time, because the sidebar's idea of "the space you are
|
|
393
|
+
in" is not necessarily the one somebody opened this from.
|
|
394
|
+
-->
|
|
395
|
+
<section class="tell">
|
|
396
|
+
<Icon name="triangle-alert" size={17} />
|
|
397
|
+
<div class="tell-body">
|
|
398
|
+
<p class="tell-title">{t('import_consequence_title', { name: space.name })}</p>
|
|
399
|
+
<p>{t('import_consequence_adds', { name: space.name })}</p>
|
|
400
|
+
<p>{t('import_consequence_keeps', { name: space.name })}</p>
|
|
401
|
+
<p>{t('import_consequence_undo')}</p>
|
|
402
|
+
<p>{t('import_consequence_report')}</p>
|
|
403
|
+
</div>
|
|
404
|
+
</section>
|
|
405
|
+
|
|
406
|
+
{#if sending}
|
|
407
|
+
<div class="upload" role="status">
|
|
408
|
+
<p class="note">{t('import_uploading')}</p>
|
|
409
|
+
{#if uploaded !== null}
|
|
410
|
+
<ProgressBar value={uploaded * 100} label={t('import_uploading')} />
|
|
411
|
+
{/if}
|
|
412
|
+
</div>
|
|
413
|
+
{/if}
|
|
414
|
+
{/if}
|
|
415
|
+
|
|
416
|
+
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
|
417
|
+
|
|
418
|
+
{#snippet footer()}
|
|
419
|
+
<div class="foot">
|
|
420
|
+
{#if job}
|
|
421
|
+
<Button variant="ghost" size="sm" onclick={again}>{t('import_another')}</Button>
|
|
422
|
+
<span class="spacer"></span>
|
|
423
|
+
<Button variant="secondary" onclick={() => (open = false)}>{t('transfer_close')}</Button>
|
|
424
|
+
{#if job.state === 'done' && job.counts.done > 0}
|
|
425
|
+
<Button href={spaceHref} onclick={() => (open = false)}>{t('import_open_space')}</Button>
|
|
426
|
+
{/if}
|
|
427
|
+
{:else}
|
|
428
|
+
<span class="spacer"></span>
|
|
429
|
+
<Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
|
|
430
|
+
<!--
|
|
431
|
+
`disabled` only for "there is no file", which is a statement about the form the drop zone
|
|
432
|
+
already makes. The second click is stopped by the flag inside `start()` instead, and there
|
|
433
|
+
is deliberately no `loading` here: `Button`'s loading state *disables* the button, and this
|
|
434
|
+
is the button somebody has their finger and their focus on — disabling it blurs it and the
|
|
435
|
+
browser hands that focus to `<body>`, so the keyboard user who pressed Import loses their
|
|
436
|
+
place on the page. The progress region above says the same thing without moving anything.
|
|
437
|
+
-->
|
|
438
|
+
<Button icon="upload" disabled={!file} onclick={() => void start()}>
|
|
439
|
+
{t('import_start', { name: space.name })}
|
|
440
|
+
</Button>
|
|
441
|
+
{/if}
|
|
442
|
+
</div>
|
|
443
|
+
{/snippet}
|
|
444
|
+
</Dialog>
|
|
445
|
+
|
|
446
|
+
<style>
|
|
447
|
+
.choices {
|
|
448
|
+
display: flex;
|
|
449
|
+
flex-direction: column;
|
|
450
|
+
gap: 6px;
|
|
451
|
+
margin: 0 0 16px;
|
|
452
|
+
padding: 0;
|
|
453
|
+
border: 0;
|
|
454
|
+
}
|
|
455
|
+
.choices legend {
|
|
456
|
+
padding: 0 0 8px;
|
|
457
|
+
font-size: 12.5px;
|
|
458
|
+
font-weight: 500;
|
|
459
|
+
color: var(--kern-ink-600);
|
|
460
|
+
}
|
|
461
|
+
.choice {
|
|
462
|
+
display: flex;
|
|
463
|
+
align-items: start;
|
|
464
|
+
gap: 10px;
|
|
465
|
+
padding: 10px 12px;
|
|
466
|
+
border: 1px solid var(--kern-border);
|
|
467
|
+
border-radius: var(--kern-r-lg);
|
|
468
|
+
cursor: pointer;
|
|
469
|
+
}
|
|
470
|
+
.choice:hover {
|
|
471
|
+
border-color: var(--kern-border-hover);
|
|
472
|
+
background: var(--kern-surface-hover);
|
|
473
|
+
}
|
|
474
|
+
.choice.on {
|
|
475
|
+
border-color: var(--kern-accent);
|
|
476
|
+
background: var(--kern-accent-tint);
|
|
477
|
+
}
|
|
478
|
+
.choice:focus-within {
|
|
479
|
+
outline: 2px solid var(--kern-ring);
|
|
480
|
+
outline-offset: 1px;
|
|
481
|
+
}
|
|
482
|
+
.choice input {
|
|
483
|
+
margin: 2px 0 0;
|
|
484
|
+
accent-color: var(--kern-accent);
|
|
485
|
+
width: 15px;
|
|
486
|
+
height: 15px;
|
|
487
|
+
flex: none;
|
|
488
|
+
}
|
|
489
|
+
.choice-body {
|
|
490
|
+
display: flex;
|
|
491
|
+
flex-direction: column;
|
|
492
|
+
gap: 2px;
|
|
493
|
+
min-width: 0;
|
|
494
|
+
}
|
|
495
|
+
.choice-title {
|
|
496
|
+
font-size: 13.5px;
|
|
497
|
+
font-weight: 500;
|
|
498
|
+
color: var(--kern-ink-900);
|
|
499
|
+
}
|
|
500
|
+
.choice-desc {
|
|
501
|
+
font-size: 12.5px;
|
|
502
|
+
line-height: 1.5;
|
|
503
|
+
color: var(--kern-ink-600);
|
|
504
|
+
text-wrap: pretty;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.drop {
|
|
508
|
+
position: relative;
|
|
509
|
+
margin-block-end: 16px;
|
|
510
|
+
}
|
|
511
|
+
/*
|
|
512
|
+
* Off screen but still in the accessibility tree and still focusable — `display: none` or
|
|
513
|
+
* `visibility: hidden` would take the only control here away from a keyboard entirely.
|
|
514
|
+
*/
|
|
515
|
+
.file {
|
|
516
|
+
position: absolute;
|
|
517
|
+
width: 1px;
|
|
518
|
+
height: 1px;
|
|
519
|
+
padding: 0;
|
|
520
|
+
margin: 0;
|
|
521
|
+
overflow: hidden;
|
|
522
|
+
clip-path: inset(50%);
|
|
523
|
+
white-space: nowrap;
|
|
524
|
+
}
|
|
525
|
+
/*
|
|
526
|
+
* `--kern-border-hover`, not `--kern-border-strong`, and the difference is only visible in dark.
|
|
527
|
+
*
|
|
528
|
+
* Measured: in the dark theme `--kern-surface-raised` resolves to the *same* colour as the dialog it
|
|
529
|
+
* sits on, so the dashed line is the only thing that says "a target", and `--kern-border-strong`
|
|
530
|
+
* against that ground is 1.34:1 — a line nobody can see. `--kern-border-hover` is 2.2:1, which is a
|
|
531
|
+
* visible dashed box in both themes. The zone stays identifiable without it either way: its icon and
|
|
532
|
+
* its two lines of text are at 5.45:1 and the input inside it draws a real focus ring.
|
|
533
|
+
*/
|
|
534
|
+
.drop label {
|
|
535
|
+
display: flex;
|
|
536
|
+
flex-direction: column;
|
|
537
|
+
align-items: center;
|
|
538
|
+
gap: 4px;
|
|
539
|
+
padding: 20px 16px;
|
|
540
|
+
border: 1px dashed var(--kern-border-hover);
|
|
541
|
+
border-radius: var(--kern-r-lg);
|
|
542
|
+
background: var(--kern-surface-input);
|
|
543
|
+
color: var(--kern-ink-500);
|
|
544
|
+
cursor: pointer;
|
|
545
|
+
text-align: center;
|
|
546
|
+
}
|
|
547
|
+
.drop label:hover,
|
|
548
|
+
.drop.on label {
|
|
549
|
+
border-color: var(--kern-accent);
|
|
550
|
+
background: var(--kern-accent-tint);
|
|
551
|
+
}
|
|
552
|
+
/* The input carries the focus and the label is what can be seen, so the ring is drawn out here. */
|
|
553
|
+
.drop:focus-within label {
|
|
554
|
+
outline: 2px solid var(--kern-ring);
|
|
555
|
+
outline-offset: 2px;
|
|
556
|
+
}
|
|
557
|
+
.drop-title {
|
|
558
|
+
font-size: 13.5px;
|
|
559
|
+
font-weight: 500;
|
|
560
|
+
color: var(--kern-ink-900);
|
|
561
|
+
overflow-wrap: anywhere;
|
|
562
|
+
}
|
|
563
|
+
.drop-hint {
|
|
564
|
+
font-size: 12.5px;
|
|
565
|
+
color: var(--kern-ink-550);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.tell {
|
|
569
|
+
display: flex;
|
|
570
|
+
gap: 10px;
|
|
571
|
+
padding: 12px 13px;
|
|
572
|
+
border-radius: var(--kern-r-lg);
|
|
573
|
+
background: var(--kern-warning-tint);
|
|
574
|
+
color: var(--kern-ink-700);
|
|
575
|
+
}
|
|
576
|
+
.tell-body {
|
|
577
|
+
display: flex;
|
|
578
|
+
flex-direction: column;
|
|
579
|
+
gap: 6px;
|
|
580
|
+
min-width: 0;
|
|
581
|
+
}
|
|
582
|
+
.tell p {
|
|
583
|
+
margin: 0;
|
|
584
|
+
font-size: 13px;
|
|
585
|
+
line-height: 1.55;
|
|
586
|
+
text-wrap: pretty;
|
|
587
|
+
}
|
|
588
|
+
.tell-title {
|
|
589
|
+
font-weight: 600;
|
|
590
|
+
font-size: 13.5px;
|
|
591
|
+
color: var(--kern-ink-900);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
.upload {
|
|
595
|
+
display: flex;
|
|
596
|
+
flex-direction: column;
|
|
597
|
+
gap: 6px;
|
|
598
|
+
margin-block-start: 14px;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
.job {
|
|
602
|
+
display: flex;
|
|
603
|
+
flex-direction: column;
|
|
604
|
+
gap: 12px;
|
|
605
|
+
}
|
|
606
|
+
.job-state {
|
|
607
|
+
display: flex;
|
|
608
|
+
align-items: center;
|
|
609
|
+
gap: 8px;
|
|
610
|
+
margin: 0;
|
|
611
|
+
font-size: 15px;
|
|
612
|
+
font-weight: 600;
|
|
613
|
+
color: var(--kern-ink-900);
|
|
614
|
+
}
|
|
615
|
+
.job-icon {
|
|
616
|
+
display: inline-flex;
|
|
617
|
+
color: var(--kern-ink-500);
|
|
618
|
+
}
|
|
619
|
+
.job-icon.bad {
|
|
620
|
+
color: var(--kern-danger);
|
|
621
|
+
}
|
|
622
|
+
.job-icon.spin {
|
|
623
|
+
animation: quire-spin 900ms linear infinite;
|
|
624
|
+
}
|
|
625
|
+
@media (prefers-reduced-motion: reduce) {
|
|
626
|
+
.job-icon.spin {
|
|
627
|
+
animation: none;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
@keyframes quire-spin {
|
|
631
|
+
to {
|
|
632
|
+
transform: rotate(360deg);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
.job-what {
|
|
636
|
+
margin: -8px 0 0;
|
|
637
|
+
font-size: 13px;
|
|
638
|
+
color: var(--kern-ink-700);
|
|
639
|
+
text-wrap: pretty;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
.note {
|
|
643
|
+
margin: 0;
|
|
644
|
+
font-size: 12.5px;
|
|
645
|
+
line-height: 1.55;
|
|
646
|
+
/* muted with a colour: `opacity` fades this against the dialog until nobody can read it */
|
|
647
|
+
color: var(--kern-ink-550);
|
|
648
|
+
text-wrap: pretty;
|
|
649
|
+
}
|
|
650
|
+
.warn {
|
|
651
|
+
margin: 0;
|
|
652
|
+
padding: 9px 11px;
|
|
653
|
+
border-radius: var(--kern-r-lg);
|
|
654
|
+
background: var(--kern-warning-tint);
|
|
655
|
+
color: var(--kern-ink-800);
|
|
656
|
+
font-size: 12.5px;
|
|
657
|
+
line-height: 1.55;
|
|
658
|
+
text-wrap: pretty;
|
|
659
|
+
}
|
|
660
|
+
.when {
|
|
661
|
+
margin: 0;
|
|
662
|
+
font-size: 12px;
|
|
663
|
+
color: var(--kern-ink-450);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.fail {
|
|
667
|
+
display: flex;
|
|
668
|
+
align-items: start;
|
|
669
|
+
gap: 8px;
|
|
670
|
+
padding: 10px 11px;
|
|
671
|
+
border-radius: var(--kern-r-lg);
|
|
672
|
+
background: var(--kern-danger-tint);
|
|
673
|
+
}
|
|
674
|
+
.fail-text {
|
|
675
|
+
flex: 1;
|
|
676
|
+
min-width: 0;
|
|
677
|
+
margin: 0;
|
|
678
|
+
font-family: var(--kern-font-mono);
|
|
679
|
+
font-size: 12px;
|
|
680
|
+
line-height: 1.5;
|
|
681
|
+
color: var(--kern-ink-800);
|
|
682
|
+
overflow-wrap: anywhere;
|
|
683
|
+
user-select: text;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
.error {
|
|
687
|
+
margin: 12px 0 0;
|
|
688
|
+
font-size: 13px;
|
|
689
|
+
color: var(--kern-danger);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
.foot {
|
|
693
|
+
display: flex;
|
|
694
|
+
align-items: center;
|
|
695
|
+
gap: 8px;
|
|
696
|
+
flex-wrap: wrap;
|
|
697
|
+
width: 100%;
|
|
698
|
+
}
|
|
699
|
+
.spacer {
|
|
700
|
+
flex: 1;
|
|
701
|
+
}
|
|
702
|
+
</style>
|