@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.
Files changed (123) hide show
  1. package/dist/contract/models.d.ts +394 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +284 -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 +114 -0
  8. package/dist/contract/permissions.js.map +1 -1
  9. package/dist/contract/properties.d.ts +7 -7
  10. package/dist/contract/router.d.ts +1124 -96
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +343 -1
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +983 -1168
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +308 -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 +351 -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/render.d.ts +158 -0
  62. package/dist/server/render.d.ts.map +1 -1
  63. package/dist/server/render.js +240 -0
  64. package/dist/server/render.js.map +1 -1
  65. package/dist/server/schema.d.ts +704 -1
  66. package/dist/server/schema.d.ts.map +1 -1
  67. package/dist/server/schema.js +254 -1
  68. package/dist/server/schema.js.map +1 -1
  69. package/dist/server/services/databases.d.ts +10 -10
  70. package/dist/server/services/export.d.ts +176 -0
  71. package/dist/server/services/export.d.ts.map +1 -0
  72. package/dist/server/services/export.js +822 -0
  73. package/dist/server/services/export.js.map +1 -0
  74. package/dist/server/services/import.d.ts +109 -0
  75. package/dist/server/services/import.d.ts.map +1 -0
  76. package/dist/server/services/import.js +570 -0
  77. package/dist/server/services/import.js.map +1 -0
  78. package/dist/server/services/index.d.ts +32 -1
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +87 -4
  81. package/dist/server/services/index.js.map +1 -1
  82. package/dist/server/services/macros.d.ts +83 -0
  83. package/dist/server/services/macros.d.ts.map +1 -0
  84. package/dist/server/services/macros.js +488 -0
  85. package/dist/server/services/macros.js.map +1 -0
  86. package/dist/server/services/publications.d.ts +2 -1
  87. package/dist/server/services/publications.d.ts.map +1 -1
  88. package/dist/server/services/publications.js +43 -4
  89. package/dist/server/services/publications.js.map +1 -1
  90. package/dist/server/services/templates.d.ts +135 -0
  91. package/dist/server/services/templates.d.ts.map +1 -0
  92. package/dist/server/services/templates.js +897 -0
  93. package/dist/server/services/templates.js.map +1 -0
  94. package/dist/server/services/versions.d.ts +13 -1
  95. package/dist/server/services/versions.d.ts.map +1 -1
  96. package/dist/server/services/versions.js +3 -1
  97. package/dist/server/services/versions.js.map +1 -1
  98. package/migrations/0010_transfers.sql +154 -0
  99. package/migrations/0011_templates.sql +157 -0
  100. package/migrations/meta/_journal.json +14 -0
  101. package/package.json +5 -5
  102. package/src/client/components/ExportDialog.svelte +685 -0
  103. package/src/client/components/ImportDialog.svelte +702 -0
  104. package/src/client/components/ImportReport.svelte +310 -0
  105. package/src/client/components/NewSpaceDialog.svelte +77 -8
  106. package/src/client/components/PageEditor.svelte +80 -0
  107. package/src/client/components/PagePicker.svelte +264 -0
  108. package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
  109. package/src/client/components/SidebarSpaces.svelte +112 -1
  110. package/src/client/components/TemplatePicker.svelte +437 -0
  111. package/src/client/i18n.ts +941 -0
  112. package/src/client/index.ts +51 -0
  113. package/src/client/mock.ts +592 -0
  114. package/src/client/module.ts +36 -0
  115. package/src/client/pages/PageView.svelte +86 -0
  116. package/src/client/pages/SpacePage.svelte +20 -4
  117. package/src/client/pages/TransfersPage.svelte +570 -0
  118. package/src/client/permissions.ts +11 -0
  119. package/src/client/query.ts +40 -0
  120. package/src/client/transfers.ts +142 -0
  121. package/src/contract/models.ts +343 -0
  122. package/src/contract/permissions.ts +117 -0
  123. package/src/contract/router.ts +375 -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,27 @@ export {
49
58
  type RecentView,
50
59
  type Space,
51
60
  type SpaceVisibility,
61
+ TEMPLATE_BUILT_IN_VARIABLES,
62
+ TEMPLATE_STARTER_KEYS,
63
+ type Template,
64
+ /**
65
+ * A picker row: a shipped starter, or a template somebody saved.
66
+ *
67
+ * Exported because a screen outside this package will want to draw one — the "New page" picker
68
+ * lives here, but a dashboard widget offering "start a meeting note" is the obvious next thing,
69
+ * and it should read the shape from here rather than restate it.
70
+ */
71
+ type TemplateChoice,
72
+ type TemplateKind,
73
+ type TemplateResult,
74
+ type TemplateSpaceBody,
75
+ type TemplateSpaceNode,
76
+ type TemplateStarterKey,
77
+ type TemplateSummary,
78
+ type TemplateVariable,
79
+ type TemplateVariableType,
80
+ type TransferCounts,
81
+ type TransferState,
52
82
  type VersionKind,
53
83
  type Watcher,
54
84
  type WatchState,
@@ -141,3 +171,24 @@ export {
141
171
  publicSiteBasePath,
142
172
  publicSiteUrl,
143
173
  } from './public-url.js'
174
+ /**
175
+ * The vocabulary of a transfer, as labels rather than as enum members.
176
+ *
177
+ * Exported for the same reason `descriptorFor` and `viewIcon` are: a state, a scope, a format and a
178
+ * source have exactly one right name each in each language, and anything that ends up drawing one —
179
+ * a widget, a notification renderer, a screen written later — should read it from here rather than
180
+ * re-deriving a `switch` that has to be kept in step with the contract's enums.
181
+ */
182
+ export {
183
+ EXPORT_FORMATS,
184
+ formatDescription,
185
+ formatLabel,
186
+ IMPORT_SOURCES,
187
+ isRunning,
188
+ progressRatio,
189
+ scopeLabel,
190
+ sourceDescription,
191
+ sourceLabel,
192
+ stateIcon,
193
+ stateLabel,
194
+ } 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,12 @@ import type {
18
24
  RecentEntry,
19
25
  RowRef,
20
26
  Space,
27
+ Template,
28
+ TemplateChoice,
29
+ TemplateKind,
30
+ TemplateResult,
31
+ TemplateVariable,
32
+ TransferCounts,
21
33
  View,
22
34
  ViewConfig,
23
35
  ViewKind,
@@ -918,6 +930,302 @@ export function createMockQuireApi() {
918
930
  return String(left).localeCompare(String(right))
919
931
  }
920
932
 
933
+ // ----------------------------------------------------------------------------------------------
934
+ // Getting work in and out
935
+ // ----------------------------------------------------------------------------------------------
936
+
937
+ /**
938
+ * A transfer is a job, so the mock has to behave like one rather than answer instantly.
939
+ *
940
+ * Every screen here is a progress bar, a spinner and a report that arrives afterwards, and a mock
941
+ * that returned `done` from `start` would leave all three unreachable — the demo and the
942
+ * end-to-end sweep would only ever see the finished state. So a started job carries a real
943
+ * `createdAt` and its state is **computed from how long ago that was**: queued for the first
944
+ * second, running while it counts, then done. No timers, so nothing keeps running after the tab
945
+ * that made it has gone.
946
+ *
947
+ * The seeded rows cover the three states somebody has to be able to look at without waiting: a
948
+ * finished export with a file, a failed one with a reason worth reading, and a finished import
949
+ * whose report has all three outcomes in it.
950
+ */
951
+ const EXPORT_STEP_MS = 320
952
+ const EXPORT_START_MS = 800
953
+ const IMPORT_READ_MS = 3200
954
+
955
+ /**
956
+ * A valid, empty zip — the 22-byte end-of-central-directory record and nothing else.
957
+ *
958
+ * There is no object storage behind `dev:mock`, so a download has to be something the browser can
959
+ * actually save; a `data:` URL is the only address that works without one. Empty rather than
960
+ * fabricated: a demo zip full of invented pages is a file somebody opens expecting their handbook.
961
+ */
962
+ const EMPTY_ZIP = 'data:application/zip;base64,UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA=='
963
+
964
+ const countsOf = (report: ImportReportEntry[]): TransferCounts => ({
965
+ total: report.length,
966
+ done: report.filter((r) => r.outcome === 'imported').length,
967
+ skipped: report.filter((r) => r.outcome === 'skipped').length,
968
+ failed: report.filter((r) => r.outcome === 'failed').length,
969
+ })
970
+
971
+ const exportJobs: ExportJob[] = [
972
+ {
973
+ id: uid(701),
974
+ workspaceId: '' as ExportJob['workspaceId'],
975
+ requestedBy: ME as ExportJob['requestedBy'],
976
+ scope: 'space',
977
+ targetId: uid(1),
978
+ format: 'markdown',
979
+ state: 'done',
980
+ fileId: uid(781),
981
+ error: null,
982
+ counts: { total: 8, done: 7, skipped: 1, failed: 0 },
983
+ createdAt: iso(72e5),
984
+ finishedAt: iso(71e5),
985
+ },
986
+ {
987
+ id: uid(702),
988
+ workspaceId: '' as ExportJob['workspaceId'],
989
+ requestedBy: ME as ExportJob['requestedBy'],
990
+ scope: 'subtree',
991
+ targetId: uid(102),
992
+ format: 'pdf',
993
+ state: 'failed',
994
+ fileId: null,
995
+ // The failure worth seeding is the one an operator can act on, written the way the service
996
+ // writes it: the PDF renderer is a separate container, and a stack with it switched off is by
997
+ // far the most common reason a PDF export dies.
998
+ error: 'The PDF service did not answer at http://gotenberg:3000 (connect ECONNREFUSED)',
999
+ counts: { total: 3, done: 3, skipped: 0, failed: 0 },
1000
+ createdAt: iso(864e5),
1001
+ finishedAt: iso(8636e4),
1002
+ },
1003
+ ]
1004
+
1005
+ /** One report with all three outcomes in it, because that is the whole point of the screen. */
1006
+ const seededImportReport: ImportReportEntry[] = [
1007
+ { path: 'Company Handbook 8f2c41d0.md', outcome: 'imported', pageId: uid(101), reason: null },
1008
+ {
1009
+ path: 'Company Handbook 8f2c41d0/Getting started 5b91aa02.md',
1010
+ outcome: 'imported',
1011
+ pageId: uid(103),
1012
+ reason: null,
1013
+ },
1014
+ {
1015
+ path: 'Company Handbook 8f2c41d0/Tasks 71cc9e10.csv',
1016
+ outcome: 'imported',
1017
+ pageId: uid(110),
1018
+ // The one `imported` row that carries a reason, exactly as the server writes it: a CSV becomes
1019
+ // a database, and the column types it guessed are the thing somebody has to check.
1020
+ reason:
1021
+ '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',
1022
+ },
1023
+ {
1024
+ path: 'Company Handbook 8f2c41d0/Tasks 71cc9e10_all.csv',
1025
+ outcome: 'skipped',
1026
+ pageId: null,
1027
+ reason: 'a second view of the same database; its rows came from “Tasks 71cc9e10.csv”',
1028
+ },
1029
+ {
1030
+ path: 'Company Handbook 8f2c41d0/diagram.png',
1031
+ outcome: 'skipped',
1032
+ pageId: null,
1033
+ reason: 'a picture, which an import cannot yet attach to a page',
1034
+ },
1035
+ {
1036
+ path: '__MACOSX/._Company Handbook 8f2c41d0.md',
1037
+ outcome: 'skipped',
1038
+ pageId: null,
1039
+ reason: 'a file the operating system added to the archive',
1040
+ },
1041
+ {
1042
+ path: 'Company Handbook 8f2c41d0/Expenses 04ab7731.md',
1043
+ outcome: 'failed',
1044
+ pageId: null,
1045
+ reason: 'its checksum does not match, so the file is damaged',
1046
+ },
1047
+ {
1048
+ path: 'Archive/Old wiki 22b0cc71.md',
1049
+ outcome: 'failed',
1050
+ pageId: null,
1051
+ reason:
1052
+ 'nothing in the archive is at this path, so the link to it in “Company Handbook” is now plain text',
1053
+ },
1054
+ ]
1055
+
1056
+ const importJobs: ImportJob[] = [
1057
+ {
1058
+ id: uid(711),
1059
+ workspaceId: '' as ImportJob['workspaceId'],
1060
+ requestedBy: ME as ImportJob['requestedBy'],
1061
+ source: 'notion',
1062
+ targetId: uid(1),
1063
+ sourceFileId: uid(791),
1064
+ state: 'done',
1065
+ error: null,
1066
+ counts: countsOf(seededImportReport),
1067
+ report: seededImportReport,
1068
+ createdAt: iso(1728e5),
1069
+ finishedAt: iso(17274e4),
1070
+ },
1071
+ ]
1072
+
1073
+ /** Move a queued export along by however long it has been sitting there. */
1074
+ function advanceExport(row: ExportJob) {
1075
+ if (row.state === 'done' || row.state === 'failed') return
1076
+ const elapsed = Date.now() - new Date(row.createdAt).getTime()
1077
+ if (elapsed < EXPORT_START_MS) return
1078
+ const total = row.counts.total || 12
1079
+ const seen = Math.min(total, Math.floor((elapsed - EXPORT_START_MS) / EXPORT_STEP_MS))
1080
+ // One page withheld, so the "some pages were left out" sentence is reachable in the demo.
1081
+ const skipped = Math.min(total > 4 ? 1 : 0, seen)
1082
+ row.counts = { total, done: seen - skipped, skipped, failed: 0 }
1083
+ row.state = seen >= total ? 'done' : 'running'
1084
+ if (row.state === 'done') {
1085
+ row.fileId = row.fileId ?? nextId()
1086
+ row.finishedAt = new Date().toISOString()
1087
+ }
1088
+ }
1089
+
1090
+ /**
1091
+ * Move a queued import along, and — when it lands — actually write the pages.
1092
+ *
1093
+ * The pages matter: an import whose report says three pages arrived and whose sidebar is unchanged
1094
+ * is a demo of the wrong thing, and "Open the space" would lead somewhere that has not moved.
1095
+ */
1096
+ function advanceImport(row: ImportJob) {
1097
+ if (row.state === 'done' || row.state === 'failed') return
1098
+ const elapsed = Date.now() - new Date(row.createdAt).getTime()
1099
+ if (elapsed < EXPORT_START_MS) return
1100
+ if (elapsed < IMPORT_READ_MS) {
1101
+ row.state = 'running'
1102
+ return
1103
+ }
1104
+ const made = ['Company Handbook', 'Getting started', 'How we work'].map((title, at) => {
1105
+ const created = page(++seq, row.targetId, title, `z${at}`, null, {})
1106
+ created.id = uid(seq)
1107
+ created.createdAt = new Date().toISOString()
1108
+ created.updatedAt = created.createdAt
1109
+ pages.push(created)
1110
+ return created.id
1111
+ })
1112
+ row.report = [
1113
+ { path: 'Company Handbook 8f2c41d0.md', outcome: 'imported', pageId: made[0] ?? null, reason: null },
1114
+ {
1115
+ path: 'Company Handbook 8f2c41d0/Getting started 5b91aa02.md',
1116
+ outcome: 'imported',
1117
+ pageId: made[1] ?? null,
1118
+ reason: null,
1119
+ },
1120
+ {
1121
+ path: 'Company Handbook 8f2c41d0/How we work 90ff1a44.md',
1122
+ outcome: 'imported',
1123
+ pageId: made[2] ?? null,
1124
+ reason: null,
1125
+ },
1126
+ {
1127
+ path: 'Company Handbook 8f2c41d0/logo.png',
1128
+ outcome: 'skipped',
1129
+ pageId: null,
1130
+ reason: 'a picture, which an import cannot yet attach to a page',
1131
+ },
1132
+ {
1133
+ path: 'Company Handbook 8f2c41d0/Notes 04ab7731.md',
1134
+ outcome: 'failed',
1135
+ pageId: null,
1136
+ reason: 'its checksum does not match, so the file is damaged',
1137
+ },
1138
+ ]
1139
+ row.counts = countsOf(row.report)
1140
+ row.state = 'done'
1141
+ row.finishedAt = new Date().toISOString()
1142
+ }
1143
+
1144
+ /**
1145
+ * The five starters, **in English only**.
1146
+ *
1147
+ * The real ones are constants on the server with a five-locale table beside them, resolved against
1148
+ * the reader's own locale — `server/services/templates.ts` explains why they cannot live in this
1149
+ * package's message bundle. This mock has no server and no principal, so it carries the English
1150
+ * names and nothing else: it is the demo interface, and a demo of the picker is a demo of the
1151
+ * shape rather than of the translation. Anything that needs to see a starter in Persian needs a
1152
+ * server.
1153
+ */
1154
+ const STARTER_NAMES: Array<[string, string, string, string]> = [
1155
+ ['meeting-notes', 'users', 'Meeting notes', 'Who was there, what was decided, and who does what next.'],
1156
+ [
1157
+ 'decision-record',
1158
+ 'flag',
1159
+ 'Decision record',
1160
+ 'One decision, why it was taken, and what it commits you to.',
1161
+ ],
1162
+ [
1163
+ 'requirements',
1164
+ 'target',
1165
+ 'Requirements',
1166
+ 'What a piece of work has to do, and what it deliberately does not.',
1167
+ ],
1168
+ [
1169
+ 'retrospective',
1170
+ 'refresh-cw',
1171
+ 'Retrospective',
1172
+ 'What went well, what got in the way, and what to change.',
1173
+ ],
1174
+ ['how-to', 'wrench', 'How-to', 'A task somebody can follow from start to finish.'],
1175
+ ]
1176
+
1177
+ /**
1178
+ * One template somebody in this workspace already saved, so the picker is not five shipped entries
1179
+ * and nothing else — the interesting half of the feature is the one a colleague made.
1180
+ */
1181
+ const templateRows: Template[] = [
1182
+ {
1183
+ id: uid(700),
1184
+ workspaceId: '' as Template['workspaceId'],
1185
+ spaceId: uid(1),
1186
+ kind: 'page',
1187
+ key: null,
1188
+ builtIn: false,
1189
+ name: 'Weekly review',
1190
+ description: 'What moved this week, and what is stuck.',
1191
+ icon: 'calendar-days',
1192
+ doc: {
1193
+ type: 'doc',
1194
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Week {{week}} · {{author}}' }] }],
1195
+ },
1196
+ variables: [
1197
+ {
1198
+ name: 'week',
1199
+ label: 'Which week',
1200
+ type: 'text',
1201
+ options: [],
1202
+ default: null,
1203
+ required: true,
1204
+ },
1205
+ ],
1206
+ createdBy: COLLEAGUE as Template['createdBy'],
1207
+ createdAt: iso(9e6),
1208
+ updatedAt: iso(9e6),
1209
+ },
1210
+ ]
1211
+
1212
+ const templateChoice = (row: Template): TemplateChoice => ({
1213
+ id: row.id,
1214
+ key: row.key,
1215
+ builtIn: row.builtIn,
1216
+ kind: row.kind,
1217
+ spaceId: row.spaceId,
1218
+ name: row.name,
1219
+ description: row.description,
1220
+ icon: row.icon,
1221
+ variables: row.variables,
1222
+ updatedAt: row.updatedAt,
1223
+ })
1224
+
1225
+ /** The same single-pass, text-only substitution the server does — see `services/templates.ts`. */
1226
+ const fill = (text: string, values: Record<string, string>) =>
1227
+ text.replace(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g, (whole, name: string) => values[name] ?? whole)
1228
+
921
1229
  return {
922
1230
  spaces: {
923
1231
  list: async ({ includeArchived = false }: { includeArchived?: boolean } = {}) =>
@@ -1380,6 +1688,290 @@ export function createMockQuireApi() {
1380
1688
  },
1381
1689
  },
1382
1690
 
1691
+ /**
1692
+ * Taking work out.
1693
+ *
1694
+ * `list` and `get` advance every job before answering, which is what makes the progress bar move
1695
+ * in `dev:mock` — there is no worker here, so the passage of time is the worker. `docx` is
1696
+ * refused exactly as the server refuses it, with the same shape of message: a mock that quietly
1697
+ * accepted it would make the one format the product does not have look like the one it does.
1698
+ */
1699
+ exports: {
1700
+ start: async (input: { scope: ExportScope; targetId: string; format: ExportFormat }) => {
1701
+ if (input.format === 'docx')
1702
+ throw Object.assign(
1703
+ new Error(
1704
+ 'Word export is not available yet. Export as HTML or PDF, both of which Word opens, ' +
1705
+ 'or as Markdown to move the pages somewhere else.',
1706
+ ),
1707
+ { code: 'BAD_REQUEST' },
1708
+ )
1709
+ if (input.scope === 'space') {
1710
+ if (!spaces.some((s) => s.id === input.targetId)) throw notFound('Space')
1711
+ } else found(input.targetId)
1712
+ const row: ExportJob = {
1713
+ id: nextId(),
1714
+ workspaceId: '' as ExportJob['workspaceId'],
1715
+ requestedBy: ME as ExportJob['requestedBy'],
1716
+ scope: input.scope,
1717
+ targetId: input.targetId,
1718
+ format: input.format,
1719
+ state: 'queued',
1720
+ fileId: null,
1721
+ error: null,
1722
+ counts: { total: 0, done: 0, skipped: 0, failed: 0 },
1723
+ createdAt: new Date().toISOString(),
1724
+ finishedAt: null,
1725
+ }
1726
+ exportJobs.unshift(row)
1727
+ return { ...row, downloadUrl: null }
1728
+ },
1729
+
1730
+ get: async ({ jobId }: { jobId: string }) => {
1731
+ const row = exportJobs.find((j) => j.id === jobId)
1732
+ if (!row) throw notFound('Export')
1733
+ advanceExport(row)
1734
+ /*
1735
+ * The link is minted here and not stored, exactly as the server does it — the download is a
1736
+ * signed URL that expires, so a row carrying one would be a row carrying an address that
1737
+ * stops working. A PDF export hands back the same empty zip: there is nothing to render.
1738
+ */
1739
+ return { ...row, downloadUrl: row.state === 'done' ? EMPTY_ZIP : null }
1740
+ },
1741
+
1742
+ list: async ({ limit = 20 }: { limit?: number } = {}) => {
1743
+ for (const row of exportJobs) advanceExport(row)
1744
+ return exportJobs.slice(0, limit).map((row) => ({ ...row }))
1745
+ },
1746
+ },
1747
+
1748
+ /**
1749
+ * Getting work in.
1750
+ *
1751
+ * `start` does not read the upload — there is no storage behind `dev:mock` and nothing to read —
1752
+ * but it does keep the two checks that decide whether the job may exist at all: the space is
1753
+ * real, and the archive is a file this mock knows about. The pages appear when the job lands,
1754
+ * so the sidebar changes underneath the report rather than the report claiming pages nobody has.
1755
+ */
1756
+ imports: {
1757
+ start: async (input: { spaceId: string; source: ImportSource; fileId: string }) => {
1758
+ if (!spaces.some((s) => s.id === input.spaceId)) throw notFound('Space')
1759
+ const row: ImportJob = {
1760
+ id: nextId(),
1761
+ workspaceId: '' as ImportJob['workspaceId'],
1762
+ requestedBy: ME as ImportJob['requestedBy'],
1763
+ source: input.source,
1764
+ targetId: input.spaceId,
1765
+ sourceFileId: input.fileId,
1766
+ state: 'queued',
1767
+ error: null,
1768
+ counts: { total: 0, done: 0, skipped: 0, failed: 0 },
1769
+ report: [],
1770
+ createdAt: new Date().toISOString(),
1771
+ finishedAt: null,
1772
+ }
1773
+ importJobs.unshift(row)
1774
+ return { ...row }
1775
+ },
1776
+
1777
+ get: async ({ jobId }: { jobId: string }) => {
1778
+ const row = importJobs.find((j) => j.id === jobId)
1779
+ if (!row) throw notFound('Import')
1780
+ advanceImport(row)
1781
+ return { ...row }
1782
+ },
1783
+
1784
+ /** Without the reports, as the server answers it — a list of thousands-of-rows reports is not a list. */
1785
+ list: async ({ limit = 20 }: { limit?: number } = {}) => {
1786
+ for (const row of importJobs) advanceImport(row)
1787
+ return importJobs.slice(0, limit).map(({ report: _report, ...summary }) => summary)
1788
+ },
1789
+ },
1790
+
1791
+ /**
1792
+ * What somebody writes with.
1793
+ *
1794
+ * The starters and the rows come back as one list with the override rule applied, exactly as the
1795
+ * server does it — a row carrying a starter's key stands in that starter's place rather than
1796
+ * appearing beside it, which is the whole shape a picker has to draw.
1797
+ *
1798
+ * `instantiate` makes a real page in the tree. It cannot write a *body*: there is no collab
1799
+ * service behind `dev:mock`, and a page's prose lives there rather than in this list. So the
1800
+ * title is substituted and the page appears where it should, and the body is the one thing a
1801
+ * demo of this feature cannot show.
1802
+ */
1803
+ templates: {
1804
+ list: async ({
1805
+ kind = 'page',
1806
+ spaceId = null,
1807
+ }: {
1808
+ kind?: TemplateKind
1809
+ spaceId?: string | null
1810
+ } = {}) => {
1811
+ const rows = templateRows.filter(
1812
+ (row) => row.kind === kind && (row.spaceId === null || row.spaceId === spaceId),
1813
+ )
1814
+ const byKey = new Map(rows.filter((row) => row.key).map((row) => [row.key as string, row]))
1815
+ const out: TemplateChoice[] = []
1816
+ if (kind === 'page')
1817
+ for (const [key, icon, name, description] of STARTER_NAMES) {
1818
+ const override = byKey.get(key)
1819
+ out.push(
1820
+ override
1821
+ ? templateChoice(override)
1822
+ : {
1823
+ id: null,
1824
+ key,
1825
+ builtIn: true,
1826
+ kind: 'page',
1827
+ spaceId: null,
1828
+ name,
1829
+ description,
1830
+ icon,
1831
+ variables: [],
1832
+ updatedAt: null,
1833
+ },
1834
+ )
1835
+ }
1836
+ for (const row of rows)
1837
+ if (!row.key || !STARTER_NAMES.some(([key]) => key === row.key)) out.push(templateChoice(row))
1838
+ return out
1839
+ },
1840
+
1841
+ get: async ({ templateId }: { templateId: string }) => {
1842
+ const row = templateRows.find((t) => t.id === templateId)
1843
+ if (!row) throw notFound('Template')
1844
+ return row
1845
+ },
1846
+
1847
+ createFromPage: async (input: {
1848
+ kind?: TemplateKind
1849
+ sourceId: string
1850
+ spaceId?: string | null
1851
+ name: string
1852
+ description?: string
1853
+ icon?: string | null
1854
+ variables?: TemplateVariable[]
1855
+ key?: string | null
1856
+ }) => {
1857
+ if (input.key && templateRows.some((t) => t.key === input.key))
1858
+ throw Object.assign(new Error('This workspace already has its own version of that template'), {
1859
+ code: 'CONFLICT',
1860
+ })
1861
+ const source = input.kind === 'space' ? null : found(input.sourceId)
1862
+ const row: Template = {
1863
+ id: nextId(),
1864
+ workspaceId: '' as Template['workspaceId'],
1865
+ spaceId: input.kind === 'space' ? null : (input.spaceId ?? null),
1866
+ kind: input.kind ?? 'page',
1867
+ key: input.key ?? null,
1868
+ builtIn: Boolean(input.key),
1869
+ name: input.name,
1870
+ description: input.description ?? '',
1871
+ icon: input.icon ?? null,
1872
+ // No collab service, so there is no body to read — the title stands in for the prose, which
1873
+ // is enough for the picker and honest about what a mock can know.
1874
+ doc:
1875
+ input.kind === 'space'
1876
+ ? { pages: [] }
1877
+ : {
1878
+ type: 'doc',
1879
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: source?.title ?? '' }] }],
1880
+ },
1881
+ variables: input.variables ?? [],
1882
+ createdBy: ME as Template['createdBy'],
1883
+ createdAt: new Date().toISOString(),
1884
+ updatedAt: new Date().toISOString(),
1885
+ }
1886
+ templateRows.push(row)
1887
+ return row
1888
+ },
1889
+
1890
+ update: async ({ templateId, ...patch }: { templateId: string } & Partial<Template>) => {
1891
+ const row = templateRows.find((t) => t.id === templateId)
1892
+ if (!row) throw notFound('Template')
1893
+ Object.assign(row, patch, { updatedAt: new Date().toISOString() })
1894
+ return row
1895
+ },
1896
+
1897
+ remove: async ({ templateId }: { templateId: string }) => {
1898
+ const at = templateRows.findIndex((t) => t.id === templateId)
1899
+ if (at < 0) throw notFound('Template')
1900
+ templateRows.splice(at, 1)
1901
+ return { ok: true as const }
1902
+ },
1903
+
1904
+ instantiate: async (input: {
1905
+ templateId?: string | null
1906
+ starterKey?: string | null
1907
+ spaceId?: string | null
1908
+ parentId?: string | null
1909
+ afterId?: string | null
1910
+ title?: string
1911
+ key?: string | null
1912
+ name?: string
1913
+ values?: Record<string, string>
1914
+ }): Promise<TemplateResult> => {
1915
+ const row = input.templateId
1916
+ ? templateRows.find((t) => t.id === input.templateId)
1917
+ : templateRows.find((t) => t.key === input.starterKey)
1918
+ const starter = STARTER_NAMES.find(([key]) => key === input.starterKey)
1919
+ if (!row && !starter) throw notFound('Template')
1920
+
1921
+ const values: Record<string, string> = {
1922
+ date: new Date().toLocaleDateString(),
1923
+ time: new Date().toLocaleTimeString(),
1924
+ author: 'You',
1925
+ space: spaces.find((s) => s.id === input.spaceId)?.name ?? '',
1926
+ ...(input.values ?? {}),
1927
+ }
1928
+ for (const variable of row?.variables ?? [])
1929
+ if (variable.required && !values[variable.name])
1930
+ throw Object.assign(new Error(`"${variable.label}" is needed before this can be made`), {
1931
+ code: 'BAD_REQUEST',
1932
+ })
1933
+
1934
+ if (row?.kind === 'space') {
1935
+ if (!input.key || !input.name)
1936
+ throw Object.assign(new Error('A space template needs a name and an address'), {
1937
+ code: 'BAD_REQUEST',
1938
+ })
1939
+ const space: Space = {
1940
+ id: nextId(),
1941
+ workspaceId: '' as Space['workspaceId'],
1942
+ key: input.key,
1943
+ name: fill(input.name, values),
1944
+ description: '',
1945
+ icon: null,
1946
+ visibility: 'open',
1947
+ homepageId: null,
1948
+ createdBy: ME as Space['createdBy'],
1949
+ createdAt: new Date().toISOString(),
1950
+ updatedAt: new Date().toISOString(),
1951
+ archivedAt: null,
1952
+ }
1953
+ spaces.push(space)
1954
+ return { spaceId: space.id, pageId: null, pageCount: 0 }
1955
+ }
1956
+
1957
+ const spaceId = input.spaceId
1958
+ if (!spaceId) throw Object.assign(new Error('A page template needs a space'), { code: 'BAD_REQUEST' })
1959
+ const siblings = pages
1960
+ .filter((p) => p.spaceId === spaceId && p.parentId === (input.parentId ?? null) && !p.deletedAt)
1961
+ .sort((a, b) => (a._order < b._order ? -1 : 1))
1962
+ const title = fill(input.title || row?.name || starter?.[2] || '', values)
1963
+ const created = page(++seq, spaceId, title, `${siblings.at(-1)?._order ?? 'a'}m`, null, {
1964
+ icon: row?.icon ?? starter?.[1] ?? null,
1965
+ })
1966
+ created.id = uid(seq)
1967
+ created.parentId = input.parentId ?? null
1968
+ created.createdAt = new Date().toISOString()
1969
+ created.updatedAt = created.createdAt
1970
+ pages.push(created)
1971
+ return { spaceId, pageId: created.id, pageCount: 1 }
1972
+ },
1973
+ },
1974
+
1383
1975
  publishing: {
1384
1976
  publish: async ({ pageId, label = null }: { pageId: string; label?: string | null }) => {
1385
1977
  const row = found(pageId)