@kernhq/module-quire 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/models.d.ts +185 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +133 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts +14 -0
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +82 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/properties.d.ts +4 -4
- package/dist/contract/router.d.ts +542 -15
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +155 -8
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +526 -15
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +166 -23
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/export/html.d.ts +67 -0
- package/dist/server/export/html.d.ts.map +1 -0
- package/dist/server/export/html.js +206 -0
- package/dist/server/export/html.js.map +1 -0
- package/dist/server/export/markdown.d.ts +51 -0
- package/dist/server/export/markdown.d.ts.map +1 -0
- package/dist/server/export/markdown.js +312 -0
- package/dist/server/export/markdown.js.map +1 -0
- package/dist/server/export/pdf.d.ts +20 -0
- package/dist/server/export/pdf.d.ts.map +1 -0
- package/dist/server/export/pdf.js +91 -0
- package/dist/server/export/pdf.js.map +1 -0
- package/dist/server/export/zip.d.ts +31 -0
- package/dist/server/export/zip.d.ts.map +1 -0
- package/dist/server/export/zip.js +158 -0
- package/dist/server/export/zip.js.map +1 -0
- package/dist/server/import/csv.d.ts +77 -0
- package/dist/server/import/csv.d.ts.map +1 -0
- package/dist/server/import/csv.js +263 -0
- package/dist/server/import/csv.js.map +1 -0
- package/dist/server/import/html.d.ts +52 -0
- package/dist/server/import/html.d.ts.map +1 -0
- package/dist/server/import/html.js +472 -0
- package/dist/server/import/html.js.map +1 -0
- package/dist/server/import/markdown.d.ts +63 -0
- package/dist/server/import/markdown.d.ts.map +1 -0
- package/dist/server/import/markdown.js +692 -0
- package/dist/server/import/markdown.js.map +1 -0
- package/dist/server/import/plan.d.ts +70 -0
- package/dist/server/import/plan.d.ts.map +1 -0
- package/dist/server/import/plan.js +761 -0
- package/dist/server/import/plan.js.map +1 -0
- package/dist/server/import/ydoc.d.ts +35 -0
- package/dist/server/import/ydoc.d.ts.map +1 -0
- package/dist/server/import/ydoc.js +91 -0
- package/dist/server/import/ydoc.js.map +1 -0
- package/dist/server/import/zip.d.ts +63 -0
- package/dist/server/import/zip.d.ts.map +1 -0
- package/dist/server/import/zip.js +308 -0
- package/dist/server/import/zip.js.map +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/schema.d.ts +445 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +146 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/databases.d.ts +5 -5
- package/dist/server/services/export.d.ts +176 -0
- package/dist/server/services/export.d.ts.map +1 -0
- package/dist/server/services/export.js +822 -0
- package/dist/server/services/export.js.map +1 -0
- package/dist/server/services/import.d.ts +109 -0
- package/dist/server/services/import.d.ts.map +1 -0
- package/dist/server/services/import.js +570 -0
- package/dist/server/services/import.js.map +1 -0
- package/dist/server/services/index.d.ts +28 -1
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +62 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/publications.d.ts +34 -1
- package/dist/server/services/publications.d.ts.map +1 -1
- package/dist/server/services/publications.js +62 -3
- package/dist/server/services/publications.js.map +1 -1
- package/dist/server/services/versions.d.ts +1 -1
- package/migrations/0010_transfers.sql +154 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/client/components/ExportDialog.svelte +685 -0
- package/src/client/components/ImportDialog.svelte +702 -0
- package/src/client/components/ImportReport.svelte +310 -0
- package/src/client/components/SidebarSpaces.svelte +79 -0
- package/src/client/i18n.ts +614 -0
- package/src/client/index.ts +32 -0
- package/src/client/mock.ts +318 -0
- package/src/client/module.ts +36 -0
- package/src/client/pages/PageView.svelte +36 -0
- package/src/client/pages/TransfersPage.svelte +570 -0
- package/src/client/permissions.ts +11 -0
- package/src/client/query.ts +23 -0
- package/src/client/transfers.ts +142 -0
- package/src/contract/models.ts +152 -0
- package/src/contract/permissions.ts +84 -0
- package/src/contract/router.ts +170 -7
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
EmptyState,
|
|
5
|
+
Icon,
|
|
6
|
+
navigation,
|
|
7
|
+
Page,
|
|
8
|
+
PageHeader,
|
|
9
|
+
relativeTime,
|
|
10
|
+
Skeleton,
|
|
11
|
+
session,
|
|
12
|
+
toast,
|
|
13
|
+
} from '@kernhq/ui'
|
|
14
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
15
|
+
import type { ExportJob, ImportJob } from '../../contract/index.js'
|
|
16
|
+
import { getQuireApi } from '../api-instance.js'
|
|
17
|
+
import ImportReport from '../components/ImportReport.svelte'
|
|
18
|
+
import { t } from '../i18n.js'
|
|
19
|
+
import { canQuire } from '../permissions.js'
|
|
20
|
+
import { quireKeys } from '../query.js'
|
|
21
|
+
import {
|
|
22
|
+
formatLabel,
|
|
23
|
+
isRunning,
|
|
24
|
+
messageOf,
|
|
25
|
+
scopeLabel,
|
|
26
|
+
sourceLabel,
|
|
27
|
+
startDownload,
|
|
28
|
+
stateIcon,
|
|
29
|
+
stateLabel,
|
|
30
|
+
} from '../transfers.js'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Work this person has taken out of Quire, and archives they have brought in.
|
|
34
|
+
*
|
|
35
|
+
* **This screen exists because a transfer is a server job and a dialog is not.** An export takes
|
|
36
|
+
* minutes and its file is kept for a week; an import's report is the only account of what happened
|
|
37
|
+
* to a thousand files. Both of those outlive the dialog that started them, and without somewhere to
|
|
38
|
+
* come back to they were reachable only by whoever remembered which page they had been standing on.
|
|
39
|
+
*
|
|
40
|
+
* Two things it does *not* do, both deliberate:
|
|
41
|
+
*
|
|
42
|
+
* - **It is not the workspace's transfers, it is yours.** `exports.list` and `imports.list` filter
|
|
43
|
+
* on `requested_by` on the server: row-level security fences the tenant, and a tenant is not a
|
|
44
|
+
* person, so one colleague's export of the salary handbook is not in anybody else's list. There is
|
|
45
|
+
* no procedure that would answer otherwise and there should not be.
|
|
46
|
+
* - **It carries no permission of its own.** The two keys behind it are independent —
|
|
47
|
+
* `quire.page.export` is a member by default and `quire.page.import` is owner-and-admin — so a
|
|
48
|
+
* single gate on the route would either hide this from somebody who may import or show it to
|
|
49
|
+
* somebody who may do neither. Each half asks its own key instead, and asks it before making the
|
|
50
|
+
* request rather than letting the server answer 403 into an error state. (A gate here would also
|
|
51
|
+
* be worse than useless: a route the shell refuses falls through to `/quire/:space`, and the
|
|
52
|
+
* person would be told "Space not found" about a space called *transfers*.)
|
|
53
|
+
*/
|
|
54
|
+
const api = getQuireApi()
|
|
55
|
+
|
|
56
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
57
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
58
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
59
|
+
|
|
60
|
+
const canExport = $derived(canQuire('pageExport'))
|
|
61
|
+
const canImport = $derived(canQuire('pageImport'))
|
|
62
|
+
|
|
63
|
+
// ------------------------------------------------------------------------------------------------
|
|
64
|
+
// The two lists
|
|
65
|
+
// ------------------------------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Polled only while something is unfinished, and invalidated by realtime the rest of the time.
|
|
69
|
+
*
|
|
70
|
+
* The keys sit under the `export` and `import` entities the server announces on every progress
|
|
71
|
+
* write, so an open tab redraws itself as a job moves without this timer; the timer is what makes
|
|
72
|
+
* that true anyway on a dropped socket or in `dev:mock`. A list where nothing is running polls
|
|
73
|
+
* never, which is the common case and the one worth not paying for.
|
|
74
|
+
*/
|
|
75
|
+
const exportsQuery = createQuery(() => ({
|
|
76
|
+
queryKey: quireKeys.exports(workspaceId),
|
|
77
|
+
enabled: canExport && Boolean(workspaceId),
|
|
78
|
+
queryFn: () => api.exports.list({ workspaceId, limit: 20 }),
|
|
79
|
+
refetchInterval: (query: { state: { data?: ExportJob[] } }) =>
|
|
80
|
+
(query.state.data ?? []).some((row) => isRunning(row.state)) ? 2000 : false,
|
|
81
|
+
}))
|
|
82
|
+
|
|
83
|
+
const importsQuery = createQuery(() => ({
|
|
84
|
+
queryKey: quireKeys.imports(workspaceId),
|
|
85
|
+
enabled: canImport && Boolean(workspaceId),
|
|
86
|
+
queryFn: () => api.imports.list({ workspaceId, limit: 20 }),
|
|
87
|
+
refetchInterval: (query: { state: { data?: Omit<ImportJob, 'report'>[] } }) =>
|
|
88
|
+
(query.state.data ?? []).some((row) => isRunning(row.state)) ? 2000 : false,
|
|
89
|
+
}))
|
|
90
|
+
|
|
91
|
+
const exportRows = $derived(exportsQuery.data ?? [])
|
|
92
|
+
const importRows = $derived(importsQuery.data ?? [])
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The spaces, for the names on `space`-scoped rows.
|
|
96
|
+
*
|
|
97
|
+
* `includeArchived: false` matches the sidebar's call **exactly**, which is not a detail: the two
|
|
98
|
+
* share `quireKeys.spaces`, and a query differing by one argument under the same key is two queries
|
|
99
|
+
* wearing one name — whichever ran last wins, and the sidebar would start losing or gaining spaces
|
|
100
|
+
* depending on whether this screen had been opened. A row naming a space that is no longer in the
|
|
101
|
+
* list says so rather than drawing an id.
|
|
102
|
+
*/
|
|
103
|
+
const spacesQuery = createQuery(() => ({
|
|
104
|
+
queryKey: quireKeys.spaces(workspaceId),
|
|
105
|
+
enabled: Boolean(workspaceId),
|
|
106
|
+
queryFn: () => api.spaces.list({ workspaceId, includeArchived: false }),
|
|
107
|
+
}))
|
|
108
|
+
const spaceById = $derived(new Map((spacesQuery.data ?? []).map((s) => [s.id, s])))
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The titles of the pages that were exported, resolved in one query.
|
|
112
|
+
*
|
|
113
|
+
* `ExportJob` carries a `targetId` and no title, and a list of "Section · PDF · 2h ago" with no name
|
|
114
|
+
* on it is unusable the moment somebody has two. So the page-scoped targets are looked up — at most
|
|
115
|
+
* twenty, because that is the list's own limit, and each is the same `pages.get` the page screen
|
|
116
|
+
* already caches. A target that answers nothing (trashed, purged, or no longer readable) resolves to
|
|
117
|
+
* null and the row says so; it is not an error, and it must not take the list down with it.
|
|
118
|
+
*
|
|
119
|
+
* Keyed under the `page` entity so a rename anywhere reaches it, and `staleTime` because a title
|
|
120
|
+
* that changed a minute ago is not worth twenty requests on every redraw.
|
|
121
|
+
*/
|
|
122
|
+
const pageTargets = $derived(
|
|
123
|
+
Array.from(new Set(exportRows.filter((row) => row.scope !== 'space').map((row) => row.targetId))).sort(),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const titlesQuery = createQuery(() => ({
|
|
127
|
+
queryKey: ['quire', 'page', workspaceId, 'transfer-targets', pageTargets.join(',')] as const,
|
|
128
|
+
enabled: Boolean(workspaceId) && pageTargets.length > 0,
|
|
129
|
+
staleTime: 60_000,
|
|
130
|
+
queryFn: async () => {
|
|
131
|
+
const found: Record<string, { title: string; spaceId: string }> = {}
|
|
132
|
+
const answers = await Promise.all(
|
|
133
|
+
pageTargets.map((id) => api.pages.get({ workspaceId, pageId: id }).catch(() => null)),
|
|
134
|
+
)
|
|
135
|
+
pageTargets.forEach((id, at) => {
|
|
136
|
+
const row = answers[at]
|
|
137
|
+
if (row) found[id] = { title: row.title, spaceId: row.spaceId }
|
|
138
|
+
})
|
|
139
|
+
return found
|
|
140
|
+
},
|
|
141
|
+
}))
|
|
142
|
+
|
|
143
|
+
interface Named {
|
|
144
|
+
label: string
|
|
145
|
+
href: string | null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function nameOf(row: ExportJob): Named {
|
|
149
|
+
if (row.scope === 'space') {
|
|
150
|
+
const space = spaceById.get(row.targetId)
|
|
151
|
+
return space
|
|
152
|
+
? { label: space.name, href: `/${workspaceSlug}/quire/${encodeURIComponent(space.key)}` }
|
|
153
|
+
: { label: t('transfer_gone_space'), href: null }
|
|
154
|
+
}
|
|
155
|
+
const found = titlesQuery.data?.[row.targetId]
|
|
156
|
+
if (!found) return { label: titlesQuery.isPending ? '…' : t('transfer_gone_page'), href: null }
|
|
157
|
+
const space = spaceById.get(found.spaceId)
|
|
158
|
+
return {
|
|
159
|
+
label: found.title.trim() || t('untitled'),
|
|
160
|
+
href: space
|
|
161
|
+
? `/${workspaceSlug}/quire/${encodeURIComponent(space.key)}/${encodeURIComponent(row.targetId)}`
|
|
162
|
+
: null,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ------------------------------------------------------------------------------------------------
|
|
167
|
+
// One import's report
|
|
168
|
+
// ------------------------------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Which report is open, seeded from the address.
|
|
172
|
+
*
|
|
173
|
+
* `imports.list` deliberately answers **without** reports — a Notion export is thousands of files, so
|
|
174
|
+
* twenty of them in one response is megabytes to draw a table of dates. The report is fetched for the
|
|
175
|
+
* one row somebody opens, and `?import=<id>` is what lets the import dialog hand somebody a link
|
|
176
|
+
* straight to it rather than "it is in the list somewhere".
|
|
177
|
+
*/
|
|
178
|
+
let opened = $state<string | null>(navigation.search.import ?? null)
|
|
179
|
+
|
|
180
|
+
const reportQuery = createQuery(() => ({
|
|
181
|
+
queryKey: quireKeys.importJob(workspaceId, opened ?? ''),
|
|
182
|
+
enabled: Boolean(workspaceId && opened),
|
|
183
|
+
queryFn: () => api.imports.get({ workspaceId, jobId: opened as string }),
|
|
184
|
+
}))
|
|
185
|
+
const report = $derived((reportQuery.data ?? null) as ImportJob | null)
|
|
186
|
+
|
|
187
|
+
function toggleReport(id: string) {
|
|
188
|
+
opened = opened === id ? null : id
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function pageHref(spaceId: string) {
|
|
192
|
+
const space = spaceById.get(spaceId)
|
|
193
|
+
return space
|
|
194
|
+
? (pageId: string) =>
|
|
195
|
+
`/${workspaceSlug}/quire/${encodeURIComponent(space.key)}/${encodeURIComponent(pageId)}`
|
|
196
|
+
: null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ------------------------------------------------------------------------------------------------
|
|
200
|
+
// Fetching an artefact
|
|
201
|
+
// ------------------------------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
let downloadError = $state<string | null>(null)
|
|
204
|
+
/* A plain `let`, not `$state`, and never fed to a `disabled`: the row's button is the one somebody's
|
|
205
|
+
focus is on, and disabling a focused control hands that focus to `<body>`. */
|
|
206
|
+
let downloading = false
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The link is asked for at the moment of the download and never before.
|
|
210
|
+
*
|
|
211
|
+
* `exports.list` carries no URL on purpose, and `exports.get` mints one signed for fifteen minutes as
|
|
212
|
+
* it answers. Fetching twenty of them to draw twenty buttons would be twenty signatures nobody used,
|
|
213
|
+
* every one of them expiring while the list sat on screen — and the request is where the permission
|
|
214
|
+
* is checked, which is the whole reason the address is not stored on the row.
|
|
215
|
+
*/
|
|
216
|
+
async function download(row: ExportJob) {
|
|
217
|
+
if (downloading) return
|
|
218
|
+
downloading = true
|
|
219
|
+
downloadError = null
|
|
220
|
+
try {
|
|
221
|
+
const fresh = await api.exports.get({ workspaceId, jobId: row.id })
|
|
222
|
+
if (!fresh.downloadUrl) {
|
|
223
|
+
downloadError = t('export_no_link')
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
const stem = nameOf(row).label.trim() || t('untitled')
|
|
227
|
+
startDownload(fresh.downloadUrl, `${stem}.${fresh.format === 'pdf' ? 'pdf' : 'zip'}`)
|
|
228
|
+
} catch (err) {
|
|
229
|
+
downloadError = messageOf(err)
|
|
230
|
+
} finally {
|
|
231
|
+
downloading = false
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The diagnostic, onto the clipboard.
|
|
237
|
+
*
|
|
238
|
+
* The person who can act on "point GOTENBERG_URL at one that is running" is usually not the person
|
|
239
|
+
* reading it, so the failure has to be *sendable* — and this is the worst place to select it by
|
|
240
|
+
* hand: four wrapped lines of English inside a right-to-left page on a phone. `ExportDialog` and
|
|
241
|
+
* every row of `ImportReport` already offer this; a failure on the list that outlives the dialog is
|
|
242
|
+
* the one somebody is most likely to come back to, and it was the only one without a control.
|
|
243
|
+
*/
|
|
244
|
+
async function copyFailure(text: string) {
|
|
245
|
+
try {
|
|
246
|
+
await navigator.clipboard.writeText(text)
|
|
247
|
+
toast.success(t('transfer_copied'))
|
|
248
|
+
} catch {
|
|
249
|
+
// A denied clipboard is not worth an error: the text is on screen to select.
|
|
250
|
+
toast.info(t('share_copy_manually'))
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
</script>
|
|
254
|
+
|
|
255
|
+
<PageHeader
|
|
256
|
+
crumbs={[{ label: workspace?.name ?? '' }, { label: t('title'), href: `/${workspaceSlug}/quire` }, { label: t('transfers') }]}
|
|
257
|
+
title={t('transfers')}
|
|
258
|
+
subtitle={t('transfers_subtitle')}
|
|
259
|
+
/>
|
|
260
|
+
|
|
261
|
+
<Page>
|
|
262
|
+
{#if !canExport && !canImport}
|
|
263
|
+
<EmptyState icon="package" title={t('transfers_none_allowed')} description={t('transfers_none_allowed_desc')} />
|
|
264
|
+
{:else}
|
|
265
|
+
{#if canExport}
|
|
266
|
+
<section class="block">
|
|
267
|
+
<h2>{t('transfers_exports')}</h2>
|
|
268
|
+
{#if exportsQuery.isPending}
|
|
269
|
+
<div class="loading">
|
|
270
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="56px" />{/each}
|
|
271
|
+
</div>
|
|
272
|
+
{:else if exportsQuery.isError}
|
|
273
|
+
<EmptyState icon="triangle-alert" title={t('transfers_error')} description={t('transfers_error_desc')}>
|
|
274
|
+
{#snippet actions()}
|
|
275
|
+
<Button variant="secondary" size="sm" onclick={() => void exportsQuery.refetch()}>
|
|
276
|
+
{t('retry')}
|
|
277
|
+
</Button>
|
|
278
|
+
{/snippet}
|
|
279
|
+
</EmptyState>
|
|
280
|
+
{:else if exportRows.length === 0}
|
|
281
|
+
<EmptyState icon="download" title={t('transfers_exports_none')} description={t('transfers_exports_none_desc')} />
|
|
282
|
+
{:else}
|
|
283
|
+
<ul class="rows">
|
|
284
|
+
{#each exportRows as row (row.id)}
|
|
285
|
+
{@const named = nameOf(row)}
|
|
286
|
+
<li class="row">
|
|
287
|
+
<span class="mark" class:spin={isRunning(row.state)} class:bad={row.state === 'failed'}>
|
|
288
|
+
<Icon name={stateIcon(row.state)} size={16} />
|
|
289
|
+
</span>
|
|
290
|
+
<div class="body">
|
|
291
|
+
<p class="title">
|
|
292
|
+
{#if named.href}
|
|
293
|
+
<a href={named.href}>{named.label}</a>
|
|
294
|
+
{:else}
|
|
295
|
+
{named.label}
|
|
296
|
+
{/if}
|
|
297
|
+
</p>
|
|
298
|
+
<p class="meta">
|
|
299
|
+
{t('transfer_export_meta', {
|
|
300
|
+
scope: scopeLabel(row.scope),
|
|
301
|
+
format: formatLabel(row.format),
|
|
302
|
+
state: stateLabel(row.state),
|
|
303
|
+
when: relativeTime(row.createdAt),
|
|
304
|
+
})}
|
|
305
|
+
</p>
|
|
306
|
+
{#if row.state === 'done' && row.counts.skipped > 0}
|
|
307
|
+
<p class="meta warn">{t('export_skipped', { count: row.counts.skipped })}</p>
|
|
308
|
+
{/if}
|
|
309
|
+
{#if row.state === 'failed' && row.error}
|
|
310
|
+
<!-- `dir="auto"`: the server's own words, English on every instance, so it must
|
|
311
|
+
not inherit an RTL paragraph direction and break at its punctuation. -->
|
|
312
|
+
<div class="fail">
|
|
313
|
+
<p class="fail-text" dir="auto">{row.error}</p>
|
|
314
|
+
<Button
|
|
315
|
+
size="xs"
|
|
316
|
+
variant="ghost"
|
|
317
|
+
icon="copy"
|
|
318
|
+
onclick={() => void copyFailure(row.error ?? '')}
|
|
319
|
+
>
|
|
320
|
+
{t('transfer_copy')}
|
|
321
|
+
</Button>
|
|
322
|
+
</div>
|
|
323
|
+
{/if}
|
|
324
|
+
</div>
|
|
325
|
+
{#if row.state === 'done' && row.counts.done > 0}
|
|
326
|
+
<Button size="sm" variant="secondary" icon="download" onclick={() => void download(row)}>
|
|
327
|
+
{t('export_download')}
|
|
328
|
+
</Button>
|
|
329
|
+
{/if}
|
|
330
|
+
</li>
|
|
331
|
+
{/each}
|
|
332
|
+
</ul>
|
|
333
|
+
<p class="foot-note">{t('export_kept')}</p>
|
|
334
|
+
{#if downloadError}<p class="error" role="alert">{downloadError}</p>{/if}
|
|
335
|
+
{/if}
|
|
336
|
+
</section>
|
|
337
|
+
{/if}
|
|
338
|
+
|
|
339
|
+
{#if canImport}
|
|
340
|
+
<section class="block">
|
|
341
|
+
<h2>{t('transfers_imports')}</h2>
|
|
342
|
+
{#if importsQuery.isPending}
|
|
343
|
+
<div class="loading">
|
|
344
|
+
{#each [1, 2] as n (n)}<Skeleton height="56px" />{/each}
|
|
345
|
+
</div>
|
|
346
|
+
{:else if importsQuery.isError}
|
|
347
|
+
<EmptyState icon="triangle-alert" title={t('transfers_error')} description={t('transfers_error_desc')}>
|
|
348
|
+
{#snippet actions()}
|
|
349
|
+
<Button variant="secondary" size="sm" onclick={() => void importsQuery.refetch()}>
|
|
350
|
+
{t('retry')}
|
|
351
|
+
</Button>
|
|
352
|
+
{/snippet}
|
|
353
|
+
</EmptyState>
|
|
354
|
+
{:else if importRows.length === 0}
|
|
355
|
+
<EmptyState icon="upload" title={t('transfers_imports_none')} description={t('transfers_imports_none_desc')} />
|
|
356
|
+
{:else}
|
|
357
|
+
<ul class="rows">
|
|
358
|
+
{#each importRows as row (row.id)}
|
|
359
|
+
{@const space = spaceById.get(row.targetId)}
|
|
360
|
+
<li class="row block-row">
|
|
361
|
+
<div class="line">
|
|
362
|
+
<span class="mark" class:spin={isRunning(row.state)} class:bad={row.state === 'failed'}>
|
|
363
|
+
<Icon name={stateIcon(row.state)} size={16} />
|
|
364
|
+
</span>
|
|
365
|
+
<div class="body">
|
|
366
|
+
<p class="title">{space?.name ?? t('transfer_gone_space')}</p>
|
|
367
|
+
<p class="meta">
|
|
368
|
+
{t('transfer_import_meta', {
|
|
369
|
+
source: sourceLabel(row.source),
|
|
370
|
+
state: stateLabel(row.state),
|
|
371
|
+
when: relativeTime(row.createdAt),
|
|
372
|
+
})}
|
|
373
|
+
</p>
|
|
374
|
+
{#if row.state === 'done'}
|
|
375
|
+
<p class="meta">
|
|
376
|
+
{t('transfer_import_counts', {
|
|
377
|
+
imported: row.counts.done,
|
|
378
|
+
skipped: row.counts.skipped,
|
|
379
|
+
failed: row.counts.failed,
|
|
380
|
+
})}
|
|
381
|
+
</p>
|
|
382
|
+
{/if}
|
|
383
|
+
{#if row.state === 'failed'}
|
|
384
|
+
<div class="fail">
|
|
385
|
+
<p class="fail-text" dir="auto">{row.error ?? t('import_failed_nothing')}</p>
|
|
386
|
+
<Button
|
|
387
|
+
size="xs"
|
|
388
|
+
variant="ghost"
|
|
389
|
+
icon="copy"
|
|
390
|
+
onclick={() => void copyFailure(row.error ?? t('import_failed_nothing'))}
|
|
391
|
+
>
|
|
392
|
+
{t('transfer_copy')}
|
|
393
|
+
</Button>
|
|
394
|
+
</div>
|
|
395
|
+
{/if}
|
|
396
|
+
</div>
|
|
397
|
+
<!--
|
|
398
|
+
`aria-expanded` on the control rather than a chevron on its own: the report is
|
|
399
|
+
drawn inside this list item, so what the button does is disclose it, and that is
|
|
400
|
+
the one fact a screen reader has no other way to learn.
|
|
401
|
+
-->
|
|
402
|
+
<Button
|
|
403
|
+
size="sm"
|
|
404
|
+
variant="secondary"
|
|
405
|
+
aria-expanded={opened === row.id}
|
|
406
|
+
onclick={() => toggleReport(row.id)}
|
|
407
|
+
>
|
|
408
|
+
{opened === row.id ? t('transfer_hide_report') : t('transfer_report')}
|
|
409
|
+
</Button>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
{#if opened === row.id}
|
|
413
|
+
<div class="report">
|
|
414
|
+
{#if reportQuery.isPending}
|
|
415
|
+
<Skeleton height="96px" />
|
|
416
|
+
{:else if reportQuery.isError}
|
|
417
|
+
<p class="error" role="alert">{t('transfers_error_desc')}</p>
|
|
418
|
+
{:else if report}
|
|
419
|
+
<ImportReport
|
|
420
|
+
report={report.report}
|
|
421
|
+
counts={report.counts}
|
|
422
|
+
link={pageHref(report.targetId)}
|
|
423
|
+
/>
|
|
424
|
+
{/if}
|
|
425
|
+
</div>
|
|
426
|
+
{/if}
|
|
427
|
+
</li>
|
|
428
|
+
{/each}
|
|
429
|
+
</ul>
|
|
430
|
+
{/if}
|
|
431
|
+
</section>
|
|
432
|
+
{/if}
|
|
433
|
+
{/if}
|
|
434
|
+
</Page>
|
|
435
|
+
|
|
436
|
+
<style>
|
|
437
|
+
.block {
|
|
438
|
+
margin-block-end: 32px;
|
|
439
|
+
}
|
|
440
|
+
.block h2 {
|
|
441
|
+
margin: 0 0 12px;
|
|
442
|
+
font-size: 15px;
|
|
443
|
+
font-weight: 600;
|
|
444
|
+
color: var(--kern-ink-900);
|
|
445
|
+
letter-spacing: -0.01em;
|
|
446
|
+
}
|
|
447
|
+
.loading {
|
|
448
|
+
display: flex;
|
|
449
|
+
flex-direction: column;
|
|
450
|
+
gap: 8px;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.rows {
|
|
454
|
+
list-style: none;
|
|
455
|
+
margin: 0;
|
|
456
|
+
padding: 0;
|
|
457
|
+
border: 1px solid var(--kern-border);
|
|
458
|
+
border-radius: var(--kern-r-lg);
|
|
459
|
+
background: var(--kern-surface);
|
|
460
|
+
overflow: hidden;
|
|
461
|
+
}
|
|
462
|
+
.row {
|
|
463
|
+
display: flex;
|
|
464
|
+
align-items: center;
|
|
465
|
+
gap: 12px;
|
|
466
|
+
padding: 12px 14px;
|
|
467
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
468
|
+
}
|
|
469
|
+
.row:last-child {
|
|
470
|
+
border-block-end: 0;
|
|
471
|
+
}
|
|
472
|
+
.block-row {
|
|
473
|
+
display: block;
|
|
474
|
+
}
|
|
475
|
+
.line {
|
|
476
|
+
display: flex;
|
|
477
|
+
align-items: center;
|
|
478
|
+
gap: 12px;
|
|
479
|
+
}
|
|
480
|
+
.mark {
|
|
481
|
+
display: inline-flex;
|
|
482
|
+
flex: none;
|
|
483
|
+
color: var(--kern-ink-500);
|
|
484
|
+
}
|
|
485
|
+
.mark.bad {
|
|
486
|
+
color: var(--kern-danger);
|
|
487
|
+
}
|
|
488
|
+
.mark.spin {
|
|
489
|
+
animation: quire-spin 900ms linear infinite;
|
|
490
|
+
}
|
|
491
|
+
@media (prefers-reduced-motion: reduce) {
|
|
492
|
+
.mark.spin {
|
|
493
|
+
animation: none;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
@keyframes quire-spin {
|
|
497
|
+
to {
|
|
498
|
+
transform: rotate(360deg);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
.body {
|
|
502
|
+
flex: 1;
|
|
503
|
+
min-width: 0;
|
|
504
|
+
}
|
|
505
|
+
.title {
|
|
506
|
+
margin: 0;
|
|
507
|
+
font-size: 13.5px;
|
|
508
|
+
font-weight: 500;
|
|
509
|
+
color: var(--kern-ink-900);
|
|
510
|
+
overflow-wrap: anywhere;
|
|
511
|
+
}
|
|
512
|
+
.title a {
|
|
513
|
+
color: inherit;
|
|
514
|
+
text-decoration: none;
|
|
515
|
+
}
|
|
516
|
+
.title a:hover {
|
|
517
|
+
text-decoration: underline;
|
|
518
|
+
}
|
|
519
|
+
.meta {
|
|
520
|
+
margin: 2px 0 0;
|
|
521
|
+
font-size: 12.5px;
|
|
522
|
+
line-height: 1.5;
|
|
523
|
+
/* muted with a colour: `opacity` here fades the row's text against the page until it is unreadable */
|
|
524
|
+
color: var(--kern-ink-550);
|
|
525
|
+
text-wrap: pretty;
|
|
526
|
+
}
|
|
527
|
+
.meta.warn {
|
|
528
|
+
color: var(--kern-ink-700);
|
|
529
|
+
}
|
|
530
|
+
/*
|
|
531
|
+
* A failure, drawn as the diagnostic it is: mono, selectable, and beside the control that sends it
|
|
532
|
+
* somewhere. The same block as `ExportDialog`'s, because it is the same sentence — a person who saw
|
|
533
|
+
* it in the dialog and comes back to the list a day later should not find a different thing.
|
|
534
|
+
*/
|
|
535
|
+
.fail {
|
|
536
|
+
display: flex;
|
|
537
|
+
align-items: start;
|
|
538
|
+
gap: 8px;
|
|
539
|
+
margin-top: 6px;
|
|
540
|
+
padding: 8px 10px;
|
|
541
|
+
border-radius: var(--kern-r-lg);
|
|
542
|
+
background: var(--kern-danger-tint);
|
|
543
|
+
}
|
|
544
|
+
.fail-text {
|
|
545
|
+
flex: 1;
|
|
546
|
+
min-width: 0;
|
|
547
|
+
margin: 0;
|
|
548
|
+
font-family: var(--kern-font-mono);
|
|
549
|
+
font-size: 12px;
|
|
550
|
+
line-height: 1.5;
|
|
551
|
+
color: var(--kern-ink-800);
|
|
552
|
+
overflow-wrap: anywhere;
|
|
553
|
+
user-select: text;
|
|
554
|
+
}
|
|
555
|
+
.report {
|
|
556
|
+
padding-block: 14px 4px;
|
|
557
|
+
/* logical, so the report indents from the right in Persian exactly as it does from the left */
|
|
558
|
+
padding-inline-start: 28px;
|
|
559
|
+
}
|
|
560
|
+
.foot-note {
|
|
561
|
+
margin: 10px 0 0;
|
|
562
|
+
font-size: 12.5px;
|
|
563
|
+
color: var(--kern-ink-550);
|
|
564
|
+
}
|
|
565
|
+
.error {
|
|
566
|
+
margin: 10px 0 0;
|
|
567
|
+
font-size: 13px;
|
|
568
|
+
color: var(--kern-danger);
|
|
569
|
+
}
|
|
570
|
+
</style>
|
|
@@ -30,6 +30,17 @@ export const QUIRE_PERMISSIONS = {
|
|
|
30
30
|
pageEdit: key('page.edit'),
|
|
31
31
|
pagePublish: key('page.publish'),
|
|
32
32
|
pageDelete: key('page.delete'),
|
|
33
|
+
/**
|
|
34
|
+
* The two halves of getting work in and out, and they are deliberately not one key.
|
|
35
|
+
*
|
|
36
|
+
* `page.export` is a member by default and `page.import` is owner-and-admin, so the pair is the
|
|
37
|
+
* one place in this module where the same screen has to be built for somebody who holds one and
|
|
38
|
+
* not the other. Every entry point checks its own: the page menu's **Export** on `pageExport`,
|
|
39
|
+
* the space menu's **Import** on `pageImport`, and the transfers screen draws whichever halves
|
|
40
|
+
* the person actually has rather than one gate over both.
|
|
41
|
+
*/
|
|
42
|
+
pageExport: key('page.export'),
|
|
43
|
+
pageImport: key('page.import'),
|
|
33
44
|
} as const
|
|
34
45
|
|
|
35
46
|
export type QuirePermission = keyof typeof QUIRE_PERMISSIONS
|
package/src/client/query.ts
CHANGED
|
@@ -65,4 +65,27 @@ export const quireKeys = {
|
|
|
65
65
|
/** a relation cell's search; `row` so any row edit re-resolves the names it draws */
|
|
66
66
|
lookup: (workspaceId: string, databaseId: string, query: string) =>
|
|
67
67
|
['quire', 'row', workspaceId, databaseId, 'lookup', query] as const,
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Work on its way out and on its way in.
|
|
71
|
+
*
|
|
72
|
+
* `export` and `import` are **realtime** entity names, not cache names — the server announces a
|
|
73
|
+
* `change` on every progress write (`services/export.ts` patches the counters, then announces),
|
|
74
|
+
* so a screen holding one of these keys redraws as the job moves without polling for it. That is
|
|
75
|
+
* why the singular spelling has to match the server's exactly: `exports` here would be a key
|
|
76
|
+
* nothing ever invalidates, and a progress bar that never moves.
|
|
77
|
+
*
|
|
78
|
+
* Polling is still set up beside it in the screens, because realtime is best-effort — a dropped
|
|
79
|
+
* socket, or `dev:mock`, has no announcements at all — and "did my export finish" is a question
|
|
80
|
+
* that must be answerable without one.
|
|
81
|
+
*
|
|
82
|
+
* The list keys are per person rather than per workspace even though the workspace id is what is
|
|
83
|
+
* in them: `exports.list` and `imports.list` filter on `requested_by` on the server, so what comes
|
|
84
|
+
* back is already only this person's. A workspace-wide announcement invalidating one person's list
|
|
85
|
+
* costs a refetch and tells them nothing about anybody else, which is the right trade.
|
|
86
|
+
*/
|
|
87
|
+
exports: (workspaceId: string) => ['quire', 'export', workspaceId] as const,
|
|
88
|
+
exportJob: (workspaceId: string, jobId: string) => ['quire', 'export', workspaceId, jobId] as const,
|
|
89
|
+
imports: (workspaceId: string) => ['quire', 'import', workspaceId] as const,
|
|
90
|
+
importJob: (workspaceId: string, jobId: string) => ['quire', 'import', workspaceId, jobId] as const,
|
|
68
91
|
}
|