@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
@@ -27,8 +27,17 @@ export {
27
27
  type Comment,
28
28
  type CommentAnchor,
29
29
  type CommentThread,
30
+ type ExportFormat,
31
+ type ExportJob,
32
+ type ExportJobDetail,
33
+ type ExportScope,
30
34
  type Favorite,
31
35
  type FavoriteEntry,
36
+ type ImportJob,
37
+ type ImportJobSummary,
38
+ type ImportOutcome,
39
+ type ImportReportEntry,
40
+ type ImportSource,
32
41
  type Label,
33
42
  type LabelColour,
34
43
  MODULE_ID,
@@ -49,6 +58,8 @@ export {
49
58
  type RecentView,
50
59
  type Space,
51
60
  type SpaceVisibility,
61
+ type TransferCounts,
62
+ type TransferState,
52
63
  type VersionKind,
53
64
  type Watcher,
54
65
  type WatchState,
@@ -141,3 +152,24 @@ export {
141
152
  publicSiteBasePath,
142
153
  publicSiteUrl,
143
154
  } from './public-url.js'
155
+ /**
156
+ * The vocabulary of a transfer, as labels rather than as enum members.
157
+ *
158
+ * Exported for the same reason `descriptorFor` and `viewIcon` are: a state, a scope, a format and a
159
+ * source have exactly one right name each in each language, and anything that ends up drawing one —
160
+ * a widget, a notification renderer, a screen written later — should read it from here rather than
161
+ * re-deriving a `switch` that has to be kept in step with the contract's enums.
162
+ */
163
+ export {
164
+ EXPORT_FORMATS,
165
+ formatDescription,
166
+ formatLabel,
167
+ IMPORT_SOURCES,
168
+ isRunning,
169
+ progressRatio,
170
+ scopeLabel,
171
+ sourceDescription,
172
+ sourceLabel,
173
+ stateIcon,
174
+ stateLabel,
175
+ } from './transfers.js'
@@ -5,7 +5,13 @@ import type {
5
5
  Database,
6
6
  DatabaseRef,
7
7
  Row as DatabaseRow,
8
+ ExportFormat,
9
+ ExportJob,
10
+ ExportScope,
8
11
  FavoriteEntry,
12
+ ImportJob,
13
+ ImportReportEntry,
14
+ ImportSource,
9
15
  Label,
10
16
  LabelColour,
11
17
  Page,
@@ -18,6 +24,7 @@ import type {
18
24
  RecentEntry,
19
25
  RowRef,
20
26
  Space,
27
+ TransferCounts,
21
28
  View,
22
29
  ViewConfig,
23
30
  ViewKind,
@@ -918,6 +925,217 @@ export function createMockQuireApi() {
918
925
  return String(left).localeCompare(String(right))
919
926
  }
920
927
 
928
+ // ----------------------------------------------------------------------------------------------
929
+ // Getting work in and out
930
+ // ----------------------------------------------------------------------------------------------
931
+
932
+ /**
933
+ * A transfer is a job, so the mock has to behave like one rather than answer instantly.
934
+ *
935
+ * Every screen here is a progress bar, a spinner and a report that arrives afterwards, and a mock
936
+ * that returned `done` from `start` would leave all three unreachable — the demo and the
937
+ * end-to-end sweep would only ever see the finished state. So a started job carries a real
938
+ * `createdAt` and its state is **computed from how long ago that was**: queued for the first
939
+ * second, running while it counts, then done. No timers, so nothing keeps running after the tab
940
+ * that made it has gone.
941
+ *
942
+ * The seeded rows cover the three states somebody has to be able to look at without waiting: a
943
+ * finished export with a file, a failed one with a reason worth reading, and a finished import
944
+ * whose report has all three outcomes in it.
945
+ */
946
+ const EXPORT_STEP_MS = 320
947
+ const EXPORT_START_MS = 800
948
+ const IMPORT_READ_MS = 3200
949
+
950
+ /**
951
+ * A valid, empty zip — the 22-byte end-of-central-directory record and nothing else.
952
+ *
953
+ * There is no object storage behind `dev:mock`, so a download has to be something the browser can
954
+ * actually save; a `data:` URL is the only address that works without one. Empty rather than
955
+ * fabricated: a demo zip full of invented pages is a file somebody opens expecting their handbook.
956
+ */
957
+ const EMPTY_ZIP = 'data:application/zip;base64,UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA=='
958
+
959
+ const countsOf = (report: ImportReportEntry[]): TransferCounts => ({
960
+ total: report.length,
961
+ done: report.filter((r) => r.outcome === 'imported').length,
962
+ skipped: report.filter((r) => r.outcome === 'skipped').length,
963
+ failed: report.filter((r) => r.outcome === 'failed').length,
964
+ })
965
+
966
+ const exportJobs: ExportJob[] = [
967
+ {
968
+ id: uid(701),
969
+ workspaceId: '' as ExportJob['workspaceId'],
970
+ requestedBy: ME as ExportJob['requestedBy'],
971
+ scope: 'space',
972
+ targetId: uid(1),
973
+ format: 'markdown',
974
+ state: 'done',
975
+ fileId: uid(781),
976
+ error: null,
977
+ counts: { total: 8, done: 7, skipped: 1, failed: 0 },
978
+ createdAt: iso(72e5),
979
+ finishedAt: iso(71e5),
980
+ },
981
+ {
982
+ id: uid(702),
983
+ workspaceId: '' as ExportJob['workspaceId'],
984
+ requestedBy: ME as ExportJob['requestedBy'],
985
+ scope: 'subtree',
986
+ targetId: uid(102),
987
+ format: 'pdf',
988
+ state: 'failed',
989
+ fileId: null,
990
+ // The failure worth seeding is the one an operator can act on, written the way the service
991
+ // writes it: the PDF renderer is a separate container, and a stack with it switched off is by
992
+ // far the most common reason a PDF export dies.
993
+ error: 'The PDF service did not answer at http://gotenberg:3000 (connect ECONNREFUSED)',
994
+ counts: { total: 3, done: 3, skipped: 0, failed: 0 },
995
+ createdAt: iso(864e5),
996
+ finishedAt: iso(8636e4),
997
+ },
998
+ ]
999
+
1000
+ /** One report with all three outcomes in it, because that is the whole point of the screen. */
1001
+ const seededImportReport: ImportReportEntry[] = [
1002
+ { path: 'Company Handbook 8f2c41d0.md', outcome: 'imported', pageId: uid(101), reason: null },
1003
+ {
1004
+ path: 'Company Handbook 8f2c41d0/Getting started 5b91aa02.md',
1005
+ outcome: 'imported',
1006
+ pageId: uid(103),
1007
+ reason: null,
1008
+ },
1009
+ {
1010
+ path: 'Company Handbook 8f2c41d0/Tasks 71cc9e10.csv',
1011
+ outcome: 'imported',
1012
+ pageId: uid(110),
1013
+ // The one `imported` row that carries a reason, exactly as the server writes it: a CSV becomes
1014
+ // a database, and the column types it guessed are the thing somebody has to check.
1015
+ reason:
1016
+ 'the first column, Name, became each row’s title; Status: read as a select with 3 choices, from 4 of 4 values; Due: read as a date; Done: read as a checkbox',
1017
+ },
1018
+ {
1019
+ path: 'Company Handbook 8f2c41d0/Tasks 71cc9e10_all.csv',
1020
+ outcome: 'skipped',
1021
+ pageId: null,
1022
+ reason: 'a second view of the same database; its rows came from “Tasks 71cc9e10.csv”',
1023
+ },
1024
+ {
1025
+ path: 'Company Handbook 8f2c41d0/diagram.png',
1026
+ outcome: 'skipped',
1027
+ pageId: null,
1028
+ reason: 'a picture, which an import cannot yet attach to a page',
1029
+ },
1030
+ {
1031
+ path: '__MACOSX/._Company Handbook 8f2c41d0.md',
1032
+ outcome: 'skipped',
1033
+ pageId: null,
1034
+ reason: 'a file the operating system added to the archive',
1035
+ },
1036
+ {
1037
+ path: 'Company Handbook 8f2c41d0/Expenses 04ab7731.md',
1038
+ outcome: 'failed',
1039
+ pageId: null,
1040
+ reason: 'its checksum does not match, so the file is damaged',
1041
+ },
1042
+ {
1043
+ path: 'Archive/Old wiki 22b0cc71.md',
1044
+ outcome: 'failed',
1045
+ pageId: null,
1046
+ reason:
1047
+ 'nothing in the archive is at this path, so the link to it in “Company Handbook” is now plain text',
1048
+ },
1049
+ ]
1050
+
1051
+ const importJobs: ImportJob[] = [
1052
+ {
1053
+ id: uid(711),
1054
+ workspaceId: '' as ImportJob['workspaceId'],
1055
+ requestedBy: ME as ImportJob['requestedBy'],
1056
+ source: 'notion',
1057
+ targetId: uid(1),
1058
+ sourceFileId: uid(791),
1059
+ state: 'done',
1060
+ error: null,
1061
+ counts: countsOf(seededImportReport),
1062
+ report: seededImportReport,
1063
+ createdAt: iso(1728e5),
1064
+ finishedAt: iso(17274e4),
1065
+ },
1066
+ ]
1067
+
1068
+ /** Move a queued export along by however long it has been sitting there. */
1069
+ function advanceExport(row: ExportJob) {
1070
+ if (row.state === 'done' || row.state === 'failed') return
1071
+ const elapsed = Date.now() - new Date(row.createdAt).getTime()
1072
+ if (elapsed < EXPORT_START_MS) return
1073
+ const total = row.counts.total || 12
1074
+ const seen = Math.min(total, Math.floor((elapsed - EXPORT_START_MS) / EXPORT_STEP_MS))
1075
+ // One page withheld, so the "some pages were left out" sentence is reachable in the demo.
1076
+ const skipped = Math.min(total > 4 ? 1 : 0, seen)
1077
+ row.counts = { total, done: seen - skipped, skipped, failed: 0 }
1078
+ row.state = seen >= total ? 'done' : 'running'
1079
+ if (row.state === 'done') {
1080
+ row.fileId = row.fileId ?? nextId()
1081
+ row.finishedAt = new Date().toISOString()
1082
+ }
1083
+ }
1084
+
1085
+ /**
1086
+ * Move a queued import along, and — when it lands — actually write the pages.
1087
+ *
1088
+ * The pages matter: an import whose report says three pages arrived and whose sidebar is unchanged
1089
+ * is a demo of the wrong thing, and "Open the space" would lead somewhere that has not moved.
1090
+ */
1091
+ function advanceImport(row: ImportJob) {
1092
+ if (row.state === 'done' || row.state === 'failed') return
1093
+ const elapsed = Date.now() - new Date(row.createdAt).getTime()
1094
+ if (elapsed < EXPORT_START_MS) return
1095
+ if (elapsed < IMPORT_READ_MS) {
1096
+ row.state = 'running'
1097
+ return
1098
+ }
1099
+ const made = ['Company Handbook', 'Getting started', 'How we work'].map((title, at) => {
1100
+ const created = page(++seq, row.targetId, title, `z${at}`, null, {})
1101
+ created.id = uid(seq)
1102
+ created.createdAt = new Date().toISOString()
1103
+ created.updatedAt = created.createdAt
1104
+ pages.push(created)
1105
+ return created.id
1106
+ })
1107
+ row.report = [
1108
+ { path: 'Company Handbook 8f2c41d0.md', outcome: 'imported', pageId: made[0] ?? null, reason: null },
1109
+ {
1110
+ path: 'Company Handbook 8f2c41d0/Getting started 5b91aa02.md',
1111
+ outcome: 'imported',
1112
+ pageId: made[1] ?? null,
1113
+ reason: null,
1114
+ },
1115
+ {
1116
+ path: 'Company Handbook 8f2c41d0/How we work 90ff1a44.md',
1117
+ outcome: 'imported',
1118
+ pageId: made[2] ?? null,
1119
+ reason: null,
1120
+ },
1121
+ {
1122
+ path: 'Company Handbook 8f2c41d0/logo.png',
1123
+ outcome: 'skipped',
1124
+ pageId: null,
1125
+ reason: 'a picture, which an import cannot yet attach to a page',
1126
+ },
1127
+ {
1128
+ path: 'Company Handbook 8f2c41d0/Notes 04ab7731.md',
1129
+ outcome: 'failed',
1130
+ pageId: null,
1131
+ reason: 'its checksum does not match, so the file is damaged',
1132
+ },
1133
+ ]
1134
+ row.counts = countsOf(row.report)
1135
+ row.state = 'done'
1136
+ row.finishedAt = new Date().toISOString()
1137
+ }
1138
+
921
1139
  return {
922
1140
  spaces: {
923
1141
  list: async ({ includeArchived = false }: { includeArchived?: boolean } = {}) =>
@@ -1380,6 +1598,106 @@ export function createMockQuireApi() {
1380
1598
  },
1381
1599
  },
1382
1600
 
1601
+ /**
1602
+ * Taking work out.
1603
+ *
1604
+ * `list` and `get` advance every job before answering, which is what makes the progress bar move
1605
+ * in `dev:mock` — there is no worker here, so the passage of time is the worker. `docx` is
1606
+ * refused exactly as the server refuses it, with the same shape of message: a mock that quietly
1607
+ * accepted it would make the one format the product does not have look like the one it does.
1608
+ */
1609
+ exports: {
1610
+ start: async (input: { scope: ExportScope; targetId: string; format: ExportFormat }) => {
1611
+ if (input.format === 'docx')
1612
+ throw Object.assign(
1613
+ new Error(
1614
+ 'Word export is not available yet. Export as HTML or PDF, both of which Word opens, ' +
1615
+ 'or as Markdown to move the pages somewhere else.',
1616
+ ),
1617
+ { code: 'BAD_REQUEST' },
1618
+ )
1619
+ if (input.scope === 'space') {
1620
+ if (!spaces.some((s) => s.id === input.targetId)) throw notFound('Space')
1621
+ } else found(input.targetId)
1622
+ const row: ExportJob = {
1623
+ id: nextId(),
1624
+ workspaceId: '' as ExportJob['workspaceId'],
1625
+ requestedBy: ME as ExportJob['requestedBy'],
1626
+ scope: input.scope,
1627
+ targetId: input.targetId,
1628
+ format: input.format,
1629
+ state: 'queued',
1630
+ fileId: null,
1631
+ error: null,
1632
+ counts: { total: 0, done: 0, skipped: 0, failed: 0 },
1633
+ createdAt: new Date().toISOString(),
1634
+ finishedAt: null,
1635
+ }
1636
+ exportJobs.unshift(row)
1637
+ return { ...row, downloadUrl: null }
1638
+ },
1639
+
1640
+ get: async ({ jobId }: { jobId: string }) => {
1641
+ const row = exportJobs.find((j) => j.id === jobId)
1642
+ if (!row) throw notFound('Export')
1643
+ advanceExport(row)
1644
+ /*
1645
+ * The link is minted here and not stored, exactly as the server does it — the download is a
1646
+ * signed URL that expires, so a row carrying one would be a row carrying an address that
1647
+ * stops working. A PDF export hands back the same empty zip: there is nothing to render.
1648
+ */
1649
+ return { ...row, downloadUrl: row.state === 'done' ? EMPTY_ZIP : null }
1650
+ },
1651
+
1652
+ list: async ({ limit = 20 }: { limit?: number } = {}) => {
1653
+ for (const row of exportJobs) advanceExport(row)
1654
+ return exportJobs.slice(0, limit).map((row) => ({ ...row }))
1655
+ },
1656
+ },
1657
+
1658
+ /**
1659
+ * Getting work in.
1660
+ *
1661
+ * `start` does not read the upload — there is no storage behind `dev:mock` and nothing to read —
1662
+ * but it does keep the two checks that decide whether the job may exist at all: the space is
1663
+ * real, and the archive is a file this mock knows about. The pages appear when the job lands,
1664
+ * so the sidebar changes underneath the report rather than the report claiming pages nobody has.
1665
+ */
1666
+ imports: {
1667
+ start: async (input: { spaceId: string; source: ImportSource; fileId: string }) => {
1668
+ if (!spaces.some((s) => s.id === input.spaceId)) throw notFound('Space')
1669
+ const row: ImportJob = {
1670
+ id: nextId(),
1671
+ workspaceId: '' as ImportJob['workspaceId'],
1672
+ requestedBy: ME as ImportJob['requestedBy'],
1673
+ source: input.source,
1674
+ targetId: input.spaceId,
1675
+ sourceFileId: input.fileId,
1676
+ state: 'queued',
1677
+ error: null,
1678
+ counts: { total: 0, done: 0, skipped: 0, failed: 0 },
1679
+ report: [],
1680
+ createdAt: new Date().toISOString(),
1681
+ finishedAt: null,
1682
+ }
1683
+ importJobs.unshift(row)
1684
+ return { ...row }
1685
+ },
1686
+
1687
+ get: async ({ jobId }: { jobId: string }) => {
1688
+ const row = importJobs.find((j) => j.id === jobId)
1689
+ if (!row) throw notFound('Import')
1690
+ advanceImport(row)
1691
+ return { ...row }
1692
+ },
1693
+
1694
+ /** Without the reports, as the server answers it — a list of thousands-of-rows reports is not a list. */
1695
+ list: async ({ limit = 20 }: { limit?: number } = {}) => {
1696
+ for (const row of importJobs) advanceImport(row)
1697
+ return importJobs.slice(0, limit).map(({ report: _report, ...summary }) => summary)
1698
+ },
1699
+ },
1700
+
1383
1701
  publishing: {
1384
1702
  publish: async ({ pageId, label = null }: { pageId: string; label?: string | null }) => {
1385
1703
  const row = found(pageId)
@@ -49,6 +49,27 @@ export const quireClientModule = defineClientModule({
49
49
  component: () => import('./pages/SpacePage.svelte'),
50
50
  permission: QUIRE_PERMISSIONS.spaceView,
51
51
  },
52
+ /**
53
+ * Work on its way out and on its way in.
54
+ *
55
+ * Two literal segments, so it beats `/quire/:space` however the two are ordered — the shell
56
+ * scores a match as `literals * 1000 + segments`. Its place in this list is for a reader.
57
+ *
58
+ * **No `permission`, on purpose**, and this is the one route here without one. The screen has
59
+ * two halves behind two independent keys — `quire.page.export` is a member by default,
60
+ * `quire.page.import` is owner-and-admin — so a single gate would either hide it from somebody
61
+ * who may import or show it to somebody who may do neither; each half asks its own key instead.
62
+ * And a gate here would misbehave rather than 404: a route the shell refuses falls through to
63
+ * `/quire/:space`, which would tell the person "Space not found" about a space called
64
+ * *transfers*.
65
+ */
66
+ {
67
+ path: '/quire/transfers',
68
+ component: () => import('./pages/TransfersPage.svelte'),
69
+ get title() {
70
+ return t('transfers')
71
+ },
72
+ },
52
73
  /**
53
74
  * Declared before `/quire/:space/:page` for a reader's benefit only — the shell resolves by
54
75
  * specificity, not by order, so the two literal segments here beat the one in the page route
@@ -93,6 +114,21 @@ export const quireClientModule = defineClientModule({
93
114
  permission: QUIRE_PERMISSIONS.spaceManage,
94
115
  run: (ctx) => ctx.navigate('/quire?new=1'),
95
116
  },
117
+ /*
118
+ * The palette is the second way to a running job, and the one that works from anywhere.
119
+ * Gated on `page.export` rather than left open: the command list is a menu, and an entry that
120
+ * leads to a screen with both halves empty is an entry that teaches people to ignore the list.
121
+ * Somebody who may import and not export reaches the screen from the import dialog instead.
122
+ */
123
+ {
124
+ id: 'quire.transfers',
125
+ get label() {
126
+ return t('cmd_transfers')
127
+ },
128
+ icon: 'package',
129
+ permission: QUIRE_PERMISSIONS.pageExport,
130
+ run: (ctx) => ctx.navigate('/quire/transfers'),
131
+ },
96
132
  ],
97
133
 
98
134
  sidebar: [
@@ -22,6 +22,7 @@ import { untrack } from 'svelte'
22
22
  import { getQuireApi } from '../api-instance.js'
23
23
  import CommentsPanel from '../components/CommentsPanel.svelte'
24
24
  import ConfirmDialog from '../components/ConfirmDialog.svelte'
25
+ import ExportDialog from '../components/ExportDialog.svelte'
25
26
  import FavoriteStar from '../components/FavoriteStar.svelte'
26
27
  import PageEditor from '../components/PageEditor.svelte'
27
28
  import PageLabels from '../components/PageLabels.svelte'
@@ -266,6 +267,15 @@ async function revert() {
266
267
  let shareOpen = $state(false)
267
268
  const canPublish = $derived(canQuire('pagePublish'))
268
269
 
270
+ /**
271
+ * Taking this page out as a file.
272
+ *
273
+ * Nothing is loaded for it here: the dialog is the only thing that knows what an export needs, and
274
+ * it asks for the space, the tree and any job already running the moment it opens. This screen's
275
+ * whole part in the feature is a flag and a menu entry, which is what an entry point should be.
276
+ */
277
+ let exportOpen = $state(false)
278
+
269
279
  const publicationsQuery = createQuery(() => ({
270
280
  queryKey: quireKeys.publications(workspaceId, doc?.spaceId ?? ''),
271
281
  enabled: canPublish && Boolean(workspaceId && doc?.spaceId),
@@ -592,6 +602,25 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
592
602
  },
593
603
  ]
594
604
  : []),
605
+ /*
606
+ * Every kind of page, not only a `page`. A live doc and a database both render through
607
+ * `renderPageDoc` like anything else — a database exports as the table it is — so the
608
+ * restriction that belongs on **Share to the web** does not belong here.
609
+ *
610
+ * `page.export` rather than `page.view`, matching what `exports.start` asks: reading a
611
+ * handbook a page at a time and walking out with the whole thing in a zip are different
612
+ * acts, and an administrator is allowed to think so.
613
+ */
614
+ ...(canQuire('pageExport')
615
+ ? [
616
+ {
617
+ id: 'export',
618
+ label: t('export_menu'),
619
+ icon: 'download',
620
+ onSelect: () => (exportOpen = true),
621
+ },
622
+ ]
623
+ : []),
595
624
  {
596
625
  id: 'watch',
597
626
  label: watching ? t('watch_stop') : t('watch'),
@@ -757,6 +786,13 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
757
786
 
758
787
  <PublishDialog bind:open={shareOpen} {workspaceId} spaceId={doc.spaceId} page={doc} />
759
788
 
789
+ <ExportDialog
790
+ bind:open={exportOpen}
791
+ {workspaceId}
792
+ spaceId={doc.spaceId}
793
+ page={{ id: doc.id, title: doc.title }}
794
+ />
795
+
760
796
  <!--
761
797
  The body says nothing about numbers until it knows them. Naming a count before the tree has
762
798
  loaded would be the same silent lie in a smaller size — "it goes to the trash" for a page that