@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.
Files changed (97) hide show
  1. package/dist/contract/models.d.ts +185 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +133 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts +14 -0
  6. package/dist/contract/permissions.d.ts.map +1 -1
  7. package/dist/contract/permissions.js +82 -0
  8. package/dist/contract/permissions.js.map +1 -1
  9. package/dist/contract/properties.d.ts +4 -4
  10. package/dist/contract/router.d.ts +535 -8
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +133 -1
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +519 -8
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +128 -0
  17. package/dist/server/_impl.js.map +1 -1
  18. package/dist/server/export/html.d.ts +67 -0
  19. package/dist/server/export/html.d.ts.map +1 -0
  20. package/dist/server/export/html.js +206 -0
  21. package/dist/server/export/html.js.map +1 -0
  22. package/dist/server/export/markdown.d.ts +51 -0
  23. package/dist/server/export/markdown.d.ts.map +1 -0
  24. package/dist/server/export/markdown.js +312 -0
  25. package/dist/server/export/markdown.js.map +1 -0
  26. package/dist/server/export/pdf.d.ts +20 -0
  27. package/dist/server/export/pdf.d.ts.map +1 -0
  28. package/dist/server/export/pdf.js +91 -0
  29. package/dist/server/export/pdf.js.map +1 -0
  30. package/dist/server/export/zip.d.ts +31 -0
  31. package/dist/server/export/zip.d.ts.map +1 -0
  32. package/dist/server/export/zip.js +158 -0
  33. package/dist/server/export/zip.js.map +1 -0
  34. package/dist/server/import/csv.d.ts +77 -0
  35. package/dist/server/import/csv.d.ts.map +1 -0
  36. package/dist/server/import/csv.js +263 -0
  37. package/dist/server/import/csv.js.map +1 -0
  38. package/dist/server/import/html.d.ts +52 -0
  39. package/dist/server/import/html.d.ts.map +1 -0
  40. package/dist/server/import/html.js +472 -0
  41. package/dist/server/import/html.js.map +1 -0
  42. package/dist/server/import/markdown.d.ts +63 -0
  43. package/dist/server/import/markdown.d.ts.map +1 -0
  44. package/dist/server/import/markdown.js +692 -0
  45. package/dist/server/import/markdown.js.map +1 -0
  46. package/dist/server/import/plan.d.ts +70 -0
  47. package/dist/server/import/plan.d.ts.map +1 -0
  48. package/dist/server/import/plan.js +761 -0
  49. package/dist/server/import/plan.js.map +1 -0
  50. package/dist/server/import/ydoc.d.ts +35 -0
  51. package/dist/server/import/ydoc.d.ts.map +1 -0
  52. package/dist/server/import/ydoc.js +91 -0
  53. package/dist/server/import/ydoc.js.map +1 -0
  54. package/dist/server/import/zip.d.ts +63 -0
  55. package/dist/server/import/zip.d.ts.map +1 -0
  56. package/dist/server/import/zip.js +308 -0
  57. package/dist/server/import/zip.js.map +1 -0
  58. package/dist/server/index.d.ts.map +1 -1
  59. package/dist/server/index.js +3 -1
  60. package/dist/server/index.js.map +1 -1
  61. package/dist/server/schema.d.ts +445 -1
  62. package/dist/server/schema.d.ts.map +1 -1
  63. package/dist/server/schema.js +146 -0
  64. package/dist/server/schema.js.map +1 -1
  65. package/dist/server/services/databases.d.ts +5 -5
  66. package/dist/server/services/export.d.ts +176 -0
  67. package/dist/server/services/export.d.ts.map +1 -0
  68. package/dist/server/services/export.js +822 -0
  69. package/dist/server/services/export.js.map +1 -0
  70. package/dist/server/services/import.d.ts +109 -0
  71. package/dist/server/services/import.d.ts.map +1 -0
  72. package/dist/server/services/import.js +570 -0
  73. package/dist/server/services/import.js.map +1 -0
  74. package/dist/server/services/index.d.ts +26 -1
  75. package/dist/server/services/index.d.ts.map +1 -1
  76. package/dist/server/services/index.js +60 -1
  77. package/dist/server/services/index.js.map +1 -1
  78. package/dist/server/services/versions.d.ts +1 -1
  79. package/migrations/0010_transfers.sql +154 -0
  80. package/migrations/meta/_journal.json +7 -0
  81. package/package.json +1 -1
  82. package/src/client/components/ExportDialog.svelte +685 -0
  83. package/src/client/components/ImportDialog.svelte +702 -0
  84. package/src/client/components/ImportReport.svelte +310 -0
  85. package/src/client/components/SidebarSpaces.svelte +79 -0
  86. package/src/client/i18n.ts +614 -0
  87. package/src/client/index.ts +32 -0
  88. package/src/client/mock.ts +318 -0
  89. package/src/client/module.ts +36 -0
  90. package/src/client/pages/PageView.svelte +36 -0
  91. package/src/client/pages/TransfersPage.svelte +570 -0
  92. package/src/client/permissions.ts +11 -0
  93. package/src/client/query.ts +23 -0
  94. package/src/client/transfers.ts +142 -0
  95. package/src/contract/models.ts +152 -0
  96. package/src/contract/permissions.ts +84 -0
  97. package/src/contract/router.ts +147 -0
@@ -0,0 +1,310 @@
1
+ <script lang="ts">
2
+ import { Button, formatCount, Icon, toast } from '@kernhq/ui'
3
+ import type { ImportOutcome, ImportReportEntry, TransferCounts } from '../../contract/index.js'
4
+ import { t } from '../i18n.js'
5
+
6
+ /**
7
+ * What happened to every file in an archive.
8
+ *
9
+ * **The report is the screen.** An import that silently drops forty pages is worse than one that
10
+ * refuses, so the server writes one row per file — imported, skipped, or failed and why — and this
11
+ * is the thing that makes those rows worth writing. Three decisions in it are worth knowing:
12
+ *
13
+ * 1. **`skipped` and `failed` are drawn differently and are never added together.** A picture Quire
14
+ * deliberately cannot carry is skipped; a `.md` that should have become a page and did not is
15
+ * failed. Collapsing them into "not imported" turns a broken import into a tidy-looking one,
16
+ * which is the exact failure mode the report exists to prevent.
17
+ * 2. **A failure is copyable, one row at a time and all at once.** The person who can act on
18
+ * "its checksum does not match" is usually not the person reading it, and re-typing forty paths
19
+ * into a message is how a report gets ignored.
20
+ * 3. **The list is filtered, not truncated.** A real Notion export is thousands of files, and the
21
+ * question is almost always "what did not come in" — so the filter is the first control, and the
22
+ * counts above it are buttons rather than decoration. Rendering is capped for the browser's sake
23
+ * and says so; the cap never hides a row from *Copy every failure*, which reads the whole report.
24
+ */
25
+ interface Props {
26
+ report: readonly ImportReportEntry[]
27
+ counts: TransferCounts
28
+ /** where an imported page can be opened, when the caller knows — omit and the rows are read-only */
29
+ link?: ((pageId: string) => string) | null
30
+ }
31
+ const { report, counts, link = null }: Props = $props()
32
+
33
+ type Filter = ImportOutcome | 'all'
34
+ let filter = $state<Filter>('all')
35
+
36
+ /** Rendered at once. A thousand list items is a browser hanging, and nobody scrolls a thousand. */
37
+ const PAGE = 300
38
+ let shown = $state(PAGE)
39
+
40
+ /** Back to the top of the list whenever the question changes — a cap carried over reads as a bug. */
41
+ function choose(next: Filter) {
42
+ filter = next
43
+ shown = PAGE
44
+ }
45
+
46
+ const rows = $derived(filter === 'all' ? report : report.filter((row) => row.outcome === filter))
47
+ const visible = $derived(rows.slice(0, shown))
48
+ const failures = $derived(report.filter((row) => row.outcome === 'failed'))
49
+
50
+ const outcomeLabel = (outcome: ImportOutcome) =>
51
+ outcome === 'imported'
52
+ ? t('import_outcome_imported')
53
+ : outcome === 'skipped'
54
+ ? t('import_outcome_skipped')
55
+ : t('import_outcome_failed')
56
+
57
+ const outcomeIcon = (outcome: ImportOutcome) =>
58
+ outcome === 'imported' ? 'check' : outcome === 'skipped' ? 'minus' : 'triangle-alert'
59
+
60
+ /** One row as a line somebody can paste into a message: the path, the outcome, and the reason. */
61
+ const asText = (row: ImportReportEntry) =>
62
+ [row.path, outcomeLabel(row.outcome), row.reason ?? ''].filter(Boolean).join(' — ')
63
+
64
+ async function copy(text: string) {
65
+ try {
66
+ await navigator.clipboard.writeText(text)
67
+ toast.success(t('transfer_copied'))
68
+ } catch {
69
+ // A denied clipboard is not worth an error toast: the text is on screen to select.
70
+ toast.info(t('share_copy_manually'))
71
+ }
72
+ }
73
+
74
+ const tabs = $derived<{ value: Filter; label: string; count: number; tone: string }[]>([
75
+ { value: 'all', label: t('import_outcome_all'), count: counts.total, tone: 'all' },
76
+ { value: 'imported', label: t('import_outcome_imported'), count: counts.done, tone: 'ok' },
77
+ { value: 'skipped', label: t('import_outcome_skipped'), count: counts.skipped, tone: 'skip' },
78
+ { value: 'failed', label: t('import_outcome_failed'), count: counts.failed, tone: 'bad' },
79
+ ])
80
+ </script>
81
+
82
+ <div class="report">
83
+ <!--
84
+ The counts are the filter. Two controls saying the same four numbers is one of them going stale,
85
+ and "3 failed" is a thing people reach for with a pointer the moment they read it.
86
+ `aria-pressed` rather than a radio group: these are toggles over a list that is already on
87
+ screen, not a choice that has to be submitted.
88
+ -->
89
+ <div class="tabs">
90
+ {#each tabs as tab (tab.value)}
91
+ <button
92
+ type="button"
93
+ class="tab {tab.tone}"
94
+ class:on={filter === tab.value}
95
+ aria-pressed={filter === tab.value}
96
+ onclick={() => choose(tab.value)}
97
+ >
98
+ <!--
99
+ Through `formatCount`, not interpolated. Every other number on this screen reaches the page
100
+ through `t()`, which puts it through `Intl.NumberFormat` — these four were the only ones
101
+ written straight into the template, and on a Persian screen they were the only Latin digits
102
+ on it, sitting in the largest type. The cap is raised because a Notion export really is
103
+ thousands of files and "99+" is not an answer here.
104
+ -->
105
+ <span class="tab-n">{formatCount(tab.count, 999_999)}</span>
106
+ <span class="tab-l">{tab.label}</span>
107
+ </button>
108
+ {/each}
109
+ </div>
110
+
111
+ {#if failures.length > 0}
112
+ <div class="bulk">
113
+ <Button
114
+ size="xs"
115
+ variant="secondary"
116
+ icon="copy"
117
+ onclick={() => void copy(failures.map(asText).join('\n'))}
118
+ >
119
+ {t('import_copy_failures', { count: failures.length })}
120
+ </Button>
121
+ </div>
122
+ {/if}
123
+
124
+ {#if rows.length === 0}
125
+ <p class="none">{t('import_report_none')}</p>
126
+ {:else}
127
+ <ul class="rows">
128
+ {#each visible as row, index (`${row.path}:${index}`)}
129
+ <li class="row {row.outcome}">
130
+ <span class="mark" aria-hidden="true"><Icon name={outcomeIcon(row.outcome)} size={13} /></span>
131
+ <div class="body">
132
+ <!--
133
+ A path is a filename with slashes in it, and it is Latin whichever way the page runs.
134
+ Without its own direction the bidi algorithm lays it out against the paragraph's and a
135
+ Persian report comes apart at every slash.
136
+ -->
137
+ <p class="path">{row.path}</p>
138
+ <!--
139
+ `dir="auto"` because a reason is **not a translated string**. `ImportReportEntry.reason`
140
+ is free text the server writes — a checksum that does not match, the column types a CSV
141
+ was read as — and it is English whatever the interface language is. Left to inherit, an
142
+ English sentence inside an RTL paragraph is laid out right to left and comes apart at
143
+ its punctuation: measured in Persian, `…came from “Tasks 71cc9e10.csv”` rendered with
144
+ both quotation marks on the same side of the filename. `dir="auto"` takes the direction
145
+ from the first strong character, so it is right for this sentence today and stays right
146
+ on the day these reasons are translated.
147
+ -->
148
+ {#if row.reason}<p class="reason" dir="auto">{row.reason}</p>{/if}
149
+ </div>
150
+ <!--
151
+ The outcome as a word, not only as the icon's colour. Green and amber are the same
152
+ greyish smudge to a good number of readers, and to every screen reader.
153
+ -->
154
+ <span class="outcome">{outcomeLabel(row.outcome)}</span>
155
+ {#if row.outcome === 'imported' && row.pageId && link}
156
+ <Button size="xs" variant="ghost" href={link(row.pageId)}>{t('import_open_page')}</Button>
157
+ {:else}
158
+ <Button size="xs" variant="ghost" icon="copy" onclick={() => void copy(asText(row))}>
159
+ {t('transfer_copy')}
160
+ </Button>
161
+ {/if}
162
+ </li>
163
+ {/each}
164
+ </ul>
165
+
166
+ {#if rows.length > visible.length}
167
+ <div class="more">
168
+ <p class="none">{t('import_report_capped', { shown: visible.length, total: rows.length })}</p>
169
+ <Button size="sm" variant="secondary" onclick={() => (shown += PAGE)}>
170
+ {t('import_report_more')}
171
+ </Button>
172
+ </div>
173
+ {/if}
174
+ {/if}
175
+ </div>
176
+
177
+ <style>
178
+ .report {
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 12px;
182
+ min-width: 0;
183
+ }
184
+
185
+ .tabs {
186
+ display: flex;
187
+ flex-wrap: wrap;
188
+ gap: 8px;
189
+ }
190
+ .tab {
191
+ display: flex;
192
+ flex-direction: column;
193
+ gap: 1px;
194
+ /* 44px tall and 84px wide: a count is the smallest label on this screen and the easiest to miss */
195
+ min-width: 84px;
196
+ min-height: 46px;
197
+ padding: 6px 12px;
198
+ border: 1px solid var(--kern-border);
199
+ border-radius: var(--kern-r-lg);
200
+ background: var(--kern-surface);
201
+ color: var(--kern-ink-700);
202
+ font: inherit;
203
+ text-align: start;
204
+ cursor: pointer;
205
+ }
206
+ .tab:hover {
207
+ border-color: var(--kern-border-hover);
208
+ background: var(--kern-surface-hover);
209
+ }
210
+ .tab.on {
211
+ border-color: var(--kern-accent);
212
+ background: var(--kern-accent-tint);
213
+ }
214
+ .tab-n {
215
+ font-size: 17px;
216
+ font-weight: 600;
217
+ font-variant-numeric: tabular-nums;
218
+ color: var(--kern-ink-900);
219
+ }
220
+ .tab.ok .tab-n {
221
+ color: var(--kern-success-ink);
222
+ }
223
+ .tab.bad .tab-n {
224
+ color: var(--kern-danger);
225
+ }
226
+ .tab-l {
227
+ font-size: 12px;
228
+ color: var(--kern-ink-600);
229
+ }
230
+
231
+ .bulk {
232
+ display: flex;
233
+ }
234
+
235
+ .rows {
236
+ list-style: none;
237
+ margin: 0;
238
+ padding: 0;
239
+ display: flex;
240
+ flex-direction: column;
241
+ }
242
+ .row {
243
+ display: flex;
244
+ align-items: start;
245
+ gap: 10px;
246
+ padding-block: 8px;
247
+ border-block-end: 1px solid var(--kern-border-hairline);
248
+ }
249
+ .row:last-child {
250
+ border-block-end: 0;
251
+ }
252
+ .mark {
253
+ display: inline-flex;
254
+ margin-block-start: 1px;
255
+ color: var(--kern-ink-450);
256
+ }
257
+ .row.imported .mark {
258
+ color: var(--kern-success-ink);
259
+ }
260
+ .row.failed .mark {
261
+ color: var(--kern-danger);
262
+ }
263
+ .body {
264
+ flex: 1;
265
+ min-width: 0;
266
+ }
267
+ .path {
268
+ margin: 0;
269
+ font-family: var(--kern-font-mono);
270
+ font-size: 12px;
271
+ line-height: 1.45;
272
+ color: var(--kern-ink-800);
273
+ /* a path reads left to right in every language; isolated so it cannot reorder a Persian row */
274
+ direction: ltr;
275
+ unicode-bidi: isolate;
276
+ text-align: start;
277
+ overflow-wrap: anywhere;
278
+ }
279
+ .reason {
280
+ margin: 3px 0 0;
281
+ font-size: 12.5px;
282
+ line-height: 1.5;
283
+ color: var(--kern-ink-650);
284
+ text-wrap: pretty;
285
+ }
286
+ .row.failed .reason {
287
+ color: var(--kern-danger);
288
+ }
289
+ .outcome {
290
+ flex: none;
291
+ align-self: center;
292
+ font-size: 12px;
293
+ color: var(--kern-ink-550);
294
+ }
295
+
296
+ .none {
297
+ margin: 0;
298
+ font-size: 12.5px;
299
+ line-height: 1.5;
300
+ color: var(--kern-ink-550);
301
+ text-wrap: pretty;
302
+ }
303
+ .more {
304
+ display: flex;
305
+ align-items: center;
306
+ gap: 10px;
307
+ flex-wrap: wrap;
308
+ padding-block-start: 4px;
309
+ }
310
+ </style>
@@ -21,6 +21,8 @@ import { t } from '../i18n.js'
21
21
  import { buildPageTree, type PageTreeNode } from '../index.js'
22
22
  import { canQuire } from '../permissions.js'
23
23
  import { quireKeys } from '../query.js'
24
+ import ExportDialog from './ExportDialog.svelte'
25
+ import ImportDialog from './ImportDialog.svelte'
24
26
  import LabelManager from './LabelManager.svelte'
25
27
  import PageTreeRow from './PageTreeRow.svelte'
26
28
  import SidebarFavorites from './SidebarFavorites.svelte'
@@ -217,6 +219,55 @@ function openTrash() {
217
219
  void navigation.go(`/${workspaceSlug}/quire/${encodeURIComponent(activeSpace.key)}/trash`)
218
220
  }
219
221
 
222
+ // ---------------------------------------------------------------------------------------------
223
+ // The space menu: getting a whole space out, and getting somebody else's wiki in
224
+ // ---------------------------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Both of these are about the *space* rather than about a page, and neither had a home before.
228
+ *
229
+ * The sidebar is where a space is chosen, so it is where a space's own actions belong — the page
230
+ * menu is one page's business, and the spaces index is a list of cards. The menu is built per
231
+ * permission and is not rendered at all when it would be empty: an ellipsis that opens onto nothing
232
+ * teaches people that the ellipsis is not worth pressing.
233
+ *
234
+ * Neither dialog is mounted until it has been asked for. They both make requests on open, and an
235
+ * always-mounted pair would run them on every Quire screen for everybody.
236
+ */
237
+ let exportOpen = $state(false)
238
+ let importOpen = $state(false)
239
+
240
+ const canExport = $derived(canQuire('pageExport'))
241
+ const canImport = $derived(canQuire('pageImport'))
242
+
243
+ const spaceMenu = $derived.by((): MenuItem[] => {
244
+ const items: MenuItem[] = []
245
+ if (canExport)
246
+ items.push({
247
+ id: 'export-space',
248
+ label: t('export_space_menu'),
249
+ icon: 'download',
250
+ onSelect: () => (exportOpen = true),
251
+ })
252
+ if (canImport)
253
+ items.push({
254
+ id: 'import-space',
255
+ label: t('import_menu'),
256
+ icon: 'upload',
257
+ onSelect: () => (importOpen = true),
258
+ })
259
+ if (items.length > 0) {
260
+ items.push({ type: 'separator' })
261
+ items.push({
262
+ id: 'transfers',
263
+ label: t('transfers'),
264
+ icon: 'package',
265
+ onSelect: () => void navigation.go(`/${workspaceSlug}/quire/transfers`),
266
+ })
267
+ }
268
+ return items
269
+ })
270
+
220
271
  let creating = $state(false)
221
272
 
222
273
  async function createPage(parentId: string | null) {
@@ -273,6 +324,23 @@ async function createPage(parentId: string | null) {
273
324
  {/snippet}
274
325
  </DropdownMenu>
275
326
  {/if}
327
+ <!--
328
+ The space's own actions, beside the box that searches it. Only drawn when there is a space to
329
+ act on and at least one action the person may take.
330
+ -->
331
+ {#if activeSpace && spaceMenu.length > 0}
332
+ <DropdownMenu items={spaceMenu}>
333
+ {#snippet trigger(props: Record<string, unknown>)}
334
+ <IconButton
335
+ {...props}
336
+ icon="ellipsis"
337
+ size={30}
338
+ variant="ghost"
339
+ label={t('space_actions')}
340
+ />
341
+ {/snippet}
342
+ </DropdownMenu>
343
+ {/if}
276
344
  </div>
277
345
 
278
346
  <div class="scroll">
@@ -430,6 +498,17 @@ async function createPage(parentId: string | null) {
430
498
  <LabelManager bind:open={labelsOpen} {workspaceId} spaceId={activeSpace.id} />
431
499
  {/if}
432
500
 
501
+ <!--
502
+ Mounted with the sidebar rather than behind `{#if open}`, and it costs nothing: every query in both
503
+ dialogs is `enabled: open`, so an unopened dialog makes no request. Rendering them conditionally
504
+ would also cut the closing animation off — the component would leave the tree on the same tick the
505
+ dialog decided to close — and it is what makes `bind:open` from a menu item work at all.
506
+ -->
507
+ {#if activeSpace}
508
+ <ExportDialog bind:open={exportOpen} {workspaceId} spaceId={activeSpace.id} />
509
+ <ImportDialog bind:open={importOpen} {workspaceId} space={activeSpace} />
510
+ {/if}
511
+
433
512
  <style>
434
513
  .wrap {
435
514
  display: flex;