@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
@@ -0,0 +1,761 @@
1
+ /**
2
+ * An archive read into a plan: what pages would be created, what databases, and what happened to
3
+ * every file.
4
+ *
5
+ * **Nothing here writes anything.** That is the point of the file existing rather than the service
6
+ * doing this inline: the rule the slice is built on is that a zip which fails half way leaves the
7
+ * space untouched, and the only way to be sure of that is for the whole archive to be read, resolved
8
+ * and reported on before the first insert. `services/import.ts` takes the finished plan and writes it
9
+ * in one transaction. A failure in here is a job that failed and a space that is exactly as it was.
10
+ *
11
+ * **The report is the feature, not the by-product.** Every file in the upload gets a row saying
12
+ * whether it became a page, was deliberately left out, or could not be read — and a link that names a
13
+ * file the archive does not contain gets a row too, because "40 pages silently lost their links" is
14
+ * the failure people actually meet and the one nothing else would report. `counts.total` is exactly
15
+ * `report.length`, which is a property `import.int.test.ts` asserts rather than a claim in a comment.
16
+ *
17
+ * **Ids are minted here, before anything is written, and that is what makes links work.** A page's
18
+ * body may link to a page that appears later in the archive, so the ids have to exist before any body
19
+ * is resolved — which rules out letting the database mint them. `uuidv7` is what the rest of the
20
+ * module uses for exactly this reason.
21
+ */
22
+ import { uuidv7 } from '@kernhq/kernel';
23
+ import { coerceValue, guessColumn, parseCsv } from './csv.js';
24
+ import { classesOf, findElement, htmlToPageDoc, parseHtml, textContent } from './html.js';
25
+ import { markdownToPageDoc, splitTitle } from './markdown.js';
26
+ import { basenameOf, dirnameOf, extensionOf, resolveArchivePath, textOf } from './zip.js';
27
+ /** Enough that no real export is refused, few enough that one import cannot become an outage. */
28
+ export const MAX_PAGES = 2_000;
29
+ /** A database bigger than this is a spreadsheet somebody should keep being a spreadsheet. */
30
+ export const MAX_ROWS = 5_000;
31
+ /** A page whose file is larger than this is not prose. */
32
+ export const MAX_DOCUMENT_BYTES = 4 * 1024 * 1024;
33
+ // ------------------------------------------------------------------------------------------------
34
+ // Report building
35
+ // ------------------------------------------------------------------------------------------------
36
+ class Report {
37
+ order;
38
+ rows = [];
39
+ /** Unresolvable targets, gathered so twenty pages pointing at one missing file are one row. */
40
+ missing = new Map();
41
+ /**
42
+ * Where each path sat in the archive, so the finished report reads in the order the files did.
43
+ *
44
+ * The rows are not produced in that order and cannot be: a page's row is written after every id
45
+ * exists, a database's after its columns are guessed, and a file nobody could read is rejected
46
+ * before either. Sorting at the end is what makes the report comparable against the archive
47
+ * listing — and against the last time somebody ran the same import.
48
+ */
49
+ constructor(order) {
50
+ this.order = order;
51
+ }
52
+ imported(path, pageId, reason = null) {
53
+ this.rows.push({ path, outcome: 'imported', pageId, reason });
54
+ }
55
+ skipped(path, reason) {
56
+ this.rows.push({ path, outcome: 'skipped', pageId: null, reason });
57
+ }
58
+ failed(path, reason) {
59
+ this.rows.push({ path, outcome: 'failed', pageId: null, reason });
60
+ }
61
+ /**
62
+ * A link or a picture that named a file the archive does not hold, and the page that carried it.
63
+ *
64
+ * A picture is worth its own sentence rather than sharing the link one: "now plain text" is what
65
+ * happens to a dead link and is not what happens to a dead picture, which is drawn nowhere at all.
66
+ * Without a row it is drawn nowhere and *said* nowhere, which is the silent drop this report exists
67
+ * to make impossible. The first sighting of a path decides which sentence it gets, so a page that
68
+ * both links to and shows one missing file is still one row about one missing file.
69
+ */
70
+ unresolved(target, from, kind = 'link') {
71
+ const seen = this.missing.get(target);
72
+ if (seen)
73
+ seen.from.add(from);
74
+ else
75
+ this.missing.set(target, { kind, from: new Set([from]) });
76
+ }
77
+ /**
78
+ * The finished report: **exactly one row per file in the archive, in archive order**, followed by
79
+ * one row per link target the archive never contained.
80
+ *
81
+ * The first half is the invariant worth stating that plainly, because it is what lets `counts` be
82
+ * read as a statement about the upload rather than about the report: every file is accounted for,
83
+ * once, and nothing about a file goes into a second row. A database that was truncated, or whose
84
+ * columns were guessed, says so on the row it already has.
85
+ *
86
+ * The second half is deliberately *not* a file, and gets its own row rather than a note on the page
87
+ * that carried the link, for two reasons. A page that arrived with one dead link is an *imported*
88
+ * page, and saying anything else about it would be wrong; and twenty pages linking to one missing
89
+ * file would be twenty copies of the same sentence instead of one row naming the thing somebody has
90
+ * to go and find.
91
+ */
92
+ finish() {
93
+ const at = (path) => this.order.get(path) ?? Number.MAX_SAFE_INTEGER;
94
+ // A stable sort, so the order below is the archive's and nothing else's.
95
+ const files = this.rows
96
+ .map((row, index) => ({ row, index }))
97
+ .sort((a, b) => at(a.row.path) - at(b.row.path) || a.index - b.index)
98
+ .map((entry) => entry.row);
99
+ const extra = [...this.missing.entries()]
100
+ .sort(([a], [b]) => a.localeCompare(b))
101
+ .map(([target, { kind, from }]) => {
102
+ const names = [...from].slice(0, 3).join(', ');
103
+ const more = from.size > 3 ? ` and ${from.size - 3} more` : '';
104
+ return {
105
+ path: target,
106
+ outcome: 'skipped',
107
+ pageId: null,
108
+ reason: kind === 'picture'
109
+ ? `nothing in the archive is at this path, so the picture in ${names}${more} was left out`
110
+ : `nothing in the archive is at this path, so the link to it in ${names}${more} is now plain text`,
111
+ };
112
+ });
113
+ return [...files, ...extra];
114
+ }
115
+ }
116
+ // ------------------------------------------------------------------------------------------------
117
+ // Link and picture resolution
118
+ // ------------------------------------------------------------------------------------------------
119
+ /** Anything with a scheme is somebody else's address and is left exactly as it was written. */
120
+ const isAbsolute = (href) => /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href) || href.startsWith('//');
121
+ /** `Getting%20started%2012ab.md#section` → `Getting started 12ab.md`. */
122
+ function archiveTarget(fromKey, href) {
123
+ const cleaned = href.split('#')[0].split('?')[0];
124
+ if (cleaned === '')
125
+ return null;
126
+ let decoded = cleaned;
127
+ try {
128
+ decoded = decodeURIComponent(cleaned);
129
+ }
130
+ catch {
131
+ // A stray `%` that is not an escape: the raw path is still a path worth trying.
132
+ }
133
+ return resolveArchivePath(fromKey, decoded);
134
+ }
135
+ /** The 32-hex id Notion appends to every name, wherever it appears in a URL or a filename. */
136
+ const NOTION_ID = /([0-9a-f]{32})/i;
137
+ /**
138
+ * Rewrite what can be rewritten, degrade what cannot, and say so.
139
+ *
140
+ * Three outcomes and they are deliberately different. A link to a page that was imported becomes a
141
+ * `pageMention`, which is Quire's own way of naming a page and survives a rename. A link to a file
142
+ * the archive holds but that did not become a page — an attachment, a stylesheet — becomes plain
143
+ * text, because a link that goes nowhere is worse than a name. A link to something the archive never
144
+ * held becomes plain text *and* a row in the report, since that is the one case where somebody has
145
+ * to go and find the missing thing.
146
+ */
147
+ function resolveInline(nodes, ctx) {
148
+ const out = [];
149
+ for (const node of nodes) {
150
+ if (node.type === 'image') {
151
+ const src = typeof node.attrs?.src === 'string' ? node.attrs.src : '';
152
+ if (isAbsolute(src)) {
153
+ out.push(node);
154
+ continue;
155
+ }
156
+ /*
157
+ * A picture that lives in the archive is dropped rather than drawn, and the file's own report
158
+ * row says why. An `image` node needs a `fileId` that `core.files.get` can answer for, and core
159
+ * exposes exactly one file procedure over the broker — `files.get`. `createUpload` needs a
160
+ * *user* principal and hands back a presigned PUT for a browser, so a background job cannot
161
+ * mint a file at all; the export side reached the same wall from the other direction and wrote
162
+ * its artefact into `kernel.storage` under a key of its own. That trick does not work here,
163
+ * because nothing renders an image from a module-owned object. The day core grows a procedure
164
+ * that mints a file for a service principal, this becomes three lines and the picture arrives.
165
+ *
166
+ * A picture the archive never held has no row of its own to carry that sentence, so it gets one
167
+ * here — the same answer a dead link gets, and for the same reason: dropping a node without
168
+ * saying so is the one thing this report is for.
169
+ *
170
+ * **The alt text stays where the picture was.** It is the only thing about the picture the
171
+ * archive can still carry into the page, it was written to be read when the image is not
172
+ * there, and throwing it away turned a described diagram into a blank line. Where there is no
173
+ * alt text there is nothing to keep, and `resolveNodes` then drops the paragraph rather than
174
+ * leaving an empty block behind.
175
+ */
176
+ const picture = archiveTarget(ctx.fromKey, src);
177
+ if (picture === null || !ctx.keys.has(picture))
178
+ ctx.report.unresolved(picture ?? src, ctx.fromLabel, 'picture');
179
+ const alt = typeof node.attrs?.alt === 'string' ? node.attrs.alt.trim() : '';
180
+ if (alt)
181
+ out.push({ type: 'text', text: alt });
182
+ continue;
183
+ }
184
+ const link = (node.marks ?? []).find((mark) => mark.type === 'link');
185
+ const href = typeof link?.attrs?.href === 'string' ? link.attrs.href : null;
186
+ if (!link || !href) {
187
+ out.push(node);
188
+ continue;
189
+ }
190
+ const label = node.text ?? '';
191
+ const pageId = targetPageId(href, ctx);
192
+ if (pageId) {
193
+ const previous = out.at(-1);
194
+ // `[**Bold** and plain](page.md)` arrives as two text nodes carrying one link; two mentions of
195
+ // the same page in a row is not what the author wrote.
196
+ if (previous?.type === 'pageMention' && previous.attrs?.id === pageId) {
197
+ previous.attrs.label = `${String(previous.attrs.label ?? '')}${label}`;
198
+ continue;
199
+ }
200
+ out.push({ type: 'pageMention', attrs: { id: pageId, label: label || 'Untitled' } });
201
+ continue;
202
+ }
203
+ if (!isAbsolute(href)) {
204
+ const target = archiveTarget(ctx.fromKey, href);
205
+ // A target the archive does hold is a file that deliberately did not become a page — its own
206
+ // row already says so, and repeating it here would be a second row for one fact.
207
+ if (target === null || !ctx.keys.has(target))
208
+ ctx.report.unresolved(target ?? href, ctx.fromLabel);
209
+ out.push({ ...node, marks: (node.marks ?? []).filter((mark) => mark.type !== 'link') });
210
+ continue;
211
+ }
212
+ out.push(node);
213
+ }
214
+ return out.filter((node) => node.type !== 'text' || (node.text ?? '').length > 0);
215
+ }
216
+ /** The page a link names, whether it is an archive path or a `notion.so` address. */
217
+ function targetPageId(href, ctx) {
218
+ if (isAbsolute(href)) {
219
+ if (!/notion\.(so|site)\b/i.test(href))
220
+ return null;
221
+ const id = NOTION_ID.exec(href.replaceAll('-', ''))?.[1];
222
+ return id ? (ctx.pageBySourceId.get(id.toLowerCase()) ?? null) : null;
223
+ }
224
+ const target = archiveTarget(ctx.fromKey, href);
225
+ return target ? (ctx.pageByKey.get(target) ?? null) : null;
226
+ }
227
+ /** The same walk over every block, so a link inside a table cell is resolved like any other. */
228
+ function resolveNodes(nodes, ctx) {
229
+ const out = [];
230
+ for (const node of resolveInline(nodes, ctx)) {
231
+ if (!node.content || node.content.length === 0) {
232
+ out.push(node);
233
+ continue;
234
+ }
235
+ const content = resolveNodes(node.content, ctx);
236
+ /*
237
+ * A paragraph that held nothing but a picture the import could not attach goes with it.
238
+ *
239
+ * The picture is dropped above and its file gets a report row saying so; leaving the wrapper
240
+ * behind put a blank block in the middle of the page, which says nothing to the person reading
241
+ * it and is not what the archive contained. Only a `paragraph` — an emptied table cell has to
242
+ * keep its place in the row and an emptied list item its place in the numbering, so those are
243
+ * left exactly as they are.
244
+ */
245
+ if (content.length === 0 && node.type === 'paragraph')
246
+ continue;
247
+ out.push({ ...node, content });
248
+ }
249
+ return out;
250
+ }
251
+ function resolveDocument(doc, ctx) {
252
+ return { ...doc, content: resolveNodes(doc.content ?? [], ctx) };
253
+ }
254
+ // ------------------------------------------------------------------------------------------------
255
+ // Reading the archive
256
+ // ------------------------------------------------------------------------------------------------
257
+ /** Noise every archive from a Mac or a Windows machine carries, and nobody ever meant to import. */
258
+ const IGNORED = [
259
+ /(^|\/)__MACOSX\//,
260
+ /(^|\/)\.DS_Store$/i,
261
+ /(^|\/)Thumbs\.db$/i,
262
+ /(^|\/)desktop\.ini$/i,
263
+ /(^|\/)\._[^/]+$/,
264
+ ];
265
+ const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown', '.mdown']);
266
+ const HTML_EXTENSIONS = new Set(['.html', '.htm']);
267
+ /** The base name with its extension removed: `Team notes 12ab.md` → `Team notes 12ab`. */
268
+ const stemOf = (key) => {
269
+ const name = basenameOf(key);
270
+ const at = name.lastIndexOf('.');
271
+ return at > 0 ? name.slice(0, at) : name;
272
+ };
273
+ /**
274
+ * A Notion name split into what it is called and the id Notion appended to it.
275
+ *
276
+ * `Team notes 1a2b3c4d5e6f7890abcdef1234567890` is one page; the same name with `_all` on the end is
277
+ * the "all" view Notion writes beside a database's default one. Both halves matter: the id is what
278
+ * makes two pages of the same name different, and the name is the fallback title for a file with no
279
+ * heading in it.
280
+ */
281
+ export function splitNotionName(stem) {
282
+ const match = /^(.*?)[\s_-]*([0-9a-f]{32})(_all)?$/i.exec(stem);
283
+ if (!match)
284
+ return { name: stem, id: null, all: false };
285
+ return { name: match[1].trim() || stem, id: match[2].toLowerCase(), all: match[3] !== undefined };
286
+ }
287
+ /**
288
+ * Every path the archive lists, whatever became of it.
289
+ *
290
+ * Deliberately not filtered: a link naming a picture, an attachment, a second database view or a file
291
+ * whose bytes are damaged names something that *is* in the upload, and each of those already has a row
292
+ * of its own saying what happened to it. Only a path that appears nowhere in this set is something
293
+ * somebody has to go and find.
294
+ */
295
+ const archiveKeys = (entries) => new Set(entries.map((entry) => entry.key));
296
+ /**
297
+ * Split the archive into the files worth reading and the rows for everything else.
298
+ *
299
+ * Every rejection is a row, and each says which of the three things happened: an entry the zip reader
300
+ * could not produce bytes for is `failed`, a file Quire has no use for is `skipped`, and only what
301
+ * survives goes on to become a page.
302
+ */
303
+ function triage(entries, report, wanted, ignore) {
304
+ const out = [];
305
+ for (const entry of entries) {
306
+ if (IGNORED.some((pattern) => pattern.test(entry.key))) {
307
+ report.skipped(entry.path, 'it is an operating system file, not part of the export');
308
+ continue;
309
+ }
310
+ if (entry.error !== null) {
311
+ report.failed(entry.path, entry.error);
312
+ continue;
313
+ }
314
+ const reason = ignore(entry.key);
315
+ if (reason) {
316
+ report.skipped(entry.path, reason);
317
+ continue;
318
+ }
319
+ if (!wanted(entry.key)) {
320
+ report.skipped(entry.path, unwantedReason(entry.key));
321
+ continue;
322
+ }
323
+ if (entry.data.length > MAX_DOCUMENT_BYTES) {
324
+ report.failed(entry.path, `it is ${Math.round(entry.data.length / 1024)} KB, which is too large for a page`);
325
+ continue;
326
+ }
327
+ const text = textOf(entry.data);
328
+ if (text === null) {
329
+ report.failed(entry.path, 'it is not text, so it cannot be read as a document');
330
+ continue;
331
+ }
332
+ out.push({ entry, key: entry.key, text });
333
+ }
334
+ return out;
335
+ }
336
+ /**
337
+ * Why a file Quire will not read was left out, in the words somebody can act on.
338
+ *
339
+ * A picture gets its own sentence because it is the one people ask about, and because the answer is
340
+ * a real limit rather than a decision: see the note in `resolveInline`.
341
+ */
342
+ function unwantedReason(key) {
343
+ const extension = extensionOf(key);
344
+ if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif', '.bmp', '.ico'].includes(extension))
345
+ return 'Quire cannot yet attach a picture that arrives in an import, so it was left out of the page that used it';
346
+ if (['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.zip'].includes(extension))
347
+ return 'an attachment, which an import cannot yet carry into a page';
348
+ // Not `a ${extension || 'file with no extension'} file`, which reads "a file with no extension file".
349
+ if (extension === '')
350
+ return 'a file with no extension, which an import has no way to read as a page';
351
+ return `a ${extension} file, which an import has no way to read as a page`;
352
+ }
353
+ // ------------------------------------------------------------------------------------------------
354
+ // Databases
355
+ // ------------------------------------------------------------------------------------------------
356
+ /**
357
+ * One CSV as a database, with the guesses recorded.
358
+ *
359
+ * The **first column is the row's title** and does not become a property. That is Notion's own
360
+ * convention and every other exporter's; a database whose first column is duplicated as a text
361
+ * property is one where renaming a row leaves a stale copy of the old name in a cell nobody notices.
362
+ */
363
+ function planDatabase(candidate, name, hostPageId, report) {
364
+ const rows = parseCsv(candidate.text);
365
+ const header = rows[0];
366
+ if (!header || header.length === 0 || header.every((cell) => cell.trim() === '')) {
367
+ report.failed(candidate.entry.path, 'the file has no header row, so its columns cannot be named');
368
+ return null;
369
+ }
370
+ const body = rows.slice(1, MAX_ROWS + 1);
371
+ const titles = header[0].trim() || 'Name';
372
+ const columns = [];
373
+ const notes = [`the first column, ${titles}, became each row's title`];
374
+ // Said on the file's own row rather than on a second one, so that "a row per file" stays true —
375
+ // and because a table that arrived with its first five thousand rows was still imported.
376
+ if (rows.length - 1 > MAX_ROWS)
377
+ notes.push(`only the first ${MAX_ROWS} of ${rows.length - 1} rows were imported; a table this size ` +
378
+ 'belongs in a spreadsheet');
379
+ for (let index = 1; index < header.length; index++) {
380
+ const label = header[index].trim();
381
+ if (label === '')
382
+ continue;
383
+ const column = guessColumn(label, body.map((row) => row[index] ?? ''));
384
+ columns.push(column);
385
+ notes.push(column.note);
386
+ }
387
+ const planned = {
388
+ hostPageId,
389
+ name,
390
+ columns,
391
+ rows: body
392
+ .filter((row) => row.some((cell) => cell.trim() !== ''))
393
+ .map((row) => ({
394
+ id: uuidv7(),
395
+ title: (row[0] ?? '').trim() || 'Untitled',
396
+ values: columns.map((column, index) => coerceValue(column, row[index + 1] ?? '')),
397
+ doc: { type: 'doc', content: [] },
398
+ })),
399
+ };
400
+ /*
401
+ * The one place an `imported` row carries a `reason`, and it is deliberate.
402
+ *
403
+ * A guess nobody is told about is indistinguishable from a mistake, so "what each column was read
404
+ * as" has to reach the report — and both other shapes are worse. A second row for the same file
405
+ * would have to claim an outcome the file did not have (it was imported, not skipped), and would
406
+ * make `counts.skipped` count something that was not skipped. A row of its own for each column
407
+ * would bury the files under a hundred rows of metadata. So the row for the CSV says what it became
408
+ * and, uniquely, how. `ImportReportEntry.reason` is documented as null for an imported entry; that
409
+ * sentence is one case too narrow, and this is the case.
410
+ */
411
+ report.imported(candidate.entry.path, hostPageId, notes.join('; '));
412
+ return planned;
413
+ }
414
+ /**
415
+ * A Notion export, or a folder of Markdown — the same reader, because they are the same shape.
416
+ *
417
+ * Notion writes `Page name <32 hex>.md` beside a folder of the same name holding its children, which
418
+ * is exactly the tree a `folder/index.md` layout expresses, and exactly what Quire's own export
419
+ * writes. The differences are two: Notion appends an id to every name, and a folder of Markdown puts
420
+ * a folder's own page in `index.md` rather than beside it. Both are handled by the two lookups below,
421
+ * so one reader covers both and neither drifts from the other.
422
+ */
423
+ function planDocuments(entries, source, report) {
424
+ const candidates = triage(entries, report, (key) => MARKDOWN_EXTENSIONS.has(extensionOf(key)) || extensionOf(key) === '.csv', () => null);
425
+ const markdown = candidates.filter((c) => MARKDOWN_EXTENSIONS.has(extensionOf(c.key)));
426
+ const csv = candidates.filter((c) => extensionOf(c.key) === '.csv');
427
+ /** `folder/index.md` and `folder/README.md` are the folder's own page. */
428
+ const indexOfFolder = new Map();
429
+ for (const candidate of markdown) {
430
+ const stem = stemOf(candidate.key).toLowerCase();
431
+ if (stem === 'index' || stem === 'readme')
432
+ indexOfFolder.set(dirnameOf(candidate.key), candidate);
433
+ }
434
+ /** `A/B.md` is the page whose children live in `A/B/`, which is Notion's whole tree convention. */
435
+ const pageOfFolder = new Map();
436
+ for (const candidate of markdown) {
437
+ const folder = candidate.key.slice(0, candidate.key.length - extensionOf(candidate.key).length);
438
+ if (!pageOfFolder.has(folder))
439
+ pageOfFolder.set(folder, candidate);
440
+ }
441
+ /**
442
+ * Which CSV to import when Notion wrote two of them.
443
+ *
444
+ * `Tasks abc.csv` is the database's default view and `Tasks abc_all.csv` is the same rows under its
445
+ * "all" view. Importing both would create the database twice, so the default wins and the other
446
+ * gets a row saying which one was used — the alternative, picking whichever came first in the
447
+ * archive, is a coin toss the report cannot explain.
448
+ */
449
+ const databaseCsv = new Map();
450
+ for (const candidate of [...csv].sort((a, b) => a.key.localeCompare(b.key))) {
451
+ const { id, all } = splitNotionName(stemOf(candidate.key));
452
+ const group = id ? `${dirnameOf(candidate.key)}/${id}` : candidate.key;
453
+ const existing = databaseCsv.get(group);
454
+ if (!existing) {
455
+ databaseCsv.set(group, candidate);
456
+ continue;
457
+ }
458
+ const loser = all ? candidate : existing;
459
+ const winner = all ? existing : candidate;
460
+ databaseCsv.set(group, winner);
461
+ report.skipped(loser.entry.path, `a second view of the same database; its rows came from ${winner.entry.path}`);
462
+ }
463
+ /** The archive folder a database's row pages live in, so those pages become rows and not siblings. */
464
+ const rowFolder = new Map();
465
+ for (const candidate of databaseCsv.values()) {
466
+ const stem = stemOf(candidate.key);
467
+ const { name, id } = splitNotionName(stem);
468
+ const folder = `${dirnameOf(candidate.key) ? `${dirnameOf(candidate.key)}/` : ''}${id ? `${name} ${id}` : name}`;
469
+ rowFolder.set(folder, candidate);
470
+ }
471
+ const drafts = [];
472
+ /** Markdown files inside a database's folder: each is one row's page, not a page of its own. */
473
+ const rowPages = [];
474
+ for (const candidate of markdown) {
475
+ const folder = dirnameOf(candidate.key);
476
+ const database = rowFolder.get(folder);
477
+ if (database) {
478
+ rowPages.push({ candidate, database });
479
+ continue;
480
+ }
481
+ const stem = stemOf(candidate.key);
482
+ const { name, id } = source === 'notion' ? splitNotionName(stem) : { name: stem, id: null };
483
+ const { title, body } = splitTitle(candidate.text);
484
+ const isIndex = indexOfFolder.get(folder) === candidate;
485
+ drafts.push({
486
+ id: uuidv7(),
487
+ key: candidate.key,
488
+ path: candidate.entry.path,
489
+ // A folder's `index.md` with no heading is named after its folder rather than "index".
490
+ title: title || (isIndex ? basenameOf(folder) || name : name) || 'Untitled',
491
+ doc: markdownToPageDoc(body),
492
+ parentKey: parentOf(candidate.key, isIndex, indexOfFolder, pageOfFolder),
493
+ sourceId: id,
494
+ });
495
+ }
496
+ return assemble(drafts, report, archiveKeys(entries), (pageByKey, pageBySourceId) => {
497
+ const databases = [];
498
+ const extraPages = [];
499
+ /** Row pages already accounted for, so the sweep below cannot write a second row about one. */
500
+ const settled = new Set();
501
+ for (const candidate of databaseCsv.values()) {
502
+ const stem = stemOf(candidate.key);
503
+ const { name, id } = source === 'notion' ? splitNotionName(stem) : { name: stem, id: null };
504
+ const hostPageId = uuidv7();
505
+ const planned = planDatabase(candidate, name || 'Imported table', hostPageId, report);
506
+ if (!planned)
507
+ continue;
508
+ const parentKey = parentOf(candidate.key, false, indexOfFolder, pageOfFolder);
509
+ extraPages.push({
510
+ id: hostPageId,
511
+ key: candidate.key,
512
+ title: name || 'Imported table',
513
+ parentId: parentKey ? (pageByKey.get(parentKey) ?? null) : null,
514
+ doc: { type: 'doc', content: [] },
515
+ });
516
+ pageByKey.set(candidate.key, hostPageId);
517
+ if (id)
518
+ pageBySourceId.set(id, hostPageId);
519
+ /*
520
+ * A row's own page, matched to its row by title — Notion writes one `.md` per row inside the
521
+ * database's folder, and a row with a body is the whole reason those files are in the archive.
522
+ *
523
+ * A **queue** per title rather than one row per title, because a database is perfectly entitled
524
+ * to hold two rows called the same thing and Notion then writes two files called the same thing.
525
+ * A plain map keeps the last of each, so both files matched one row: the first file's body was
526
+ * silently thrown away, both were reported `imported`, and both rows carried the *same* page id —
527
+ * a report saying a page arrived when its text is nowhere in the space. Taking each row once
528
+ * pairs them off in archive order and leaves any genuine surplus to be reported as surplus.
529
+ */
530
+ const byTitle = new Map();
531
+ for (const row of planned.rows) {
532
+ const key = row.title.trim().toLowerCase();
533
+ const queue = byTitle.get(key);
534
+ if (queue)
535
+ queue.push(row);
536
+ else
537
+ byTitle.set(key, [row]);
538
+ }
539
+ /** Which file's body a row already took, so the surplus row can name it rather than guess. */
540
+ const taken = new Map();
541
+ for (const { candidate: page, database } of rowPages) {
542
+ if (database !== candidate)
543
+ continue;
544
+ settled.add(page);
545
+ const { title, body } = splitTitle(page.text);
546
+ const stemName = source === 'notion' ? splitNotionName(stemOf(page.key)).name : stemOf(page.key);
547
+ const rowTitle = (title || stemName).trim();
548
+ const queue = byTitle.get(rowTitle.toLowerCase());
549
+ const row = queue?.shift();
550
+ if (!row) {
551
+ const already = taken.get(rowTitle.toLowerCase());
552
+ report.skipped(page.entry.path, already
553
+ ? `every row of ${candidate.entry.path} called "${rowTitle}" already has a page; ` +
554
+ `the last one's body came from ${already}`
555
+ : `no row of ${candidate.entry.path} is called "${rowTitle}", so this page has no row to belong to`);
556
+ continue;
557
+ }
558
+ taken.set(rowTitle.toLowerCase(), page.entry.path);
559
+ row.doc = markdownToPageDoc(body);
560
+ // A row is a page, so a link to it resolves like a link to anything else.
561
+ pageByKey.set(page.key, row.id);
562
+ if (source === 'notion') {
563
+ const rowId = splitNotionName(stemOf(page.key)).id;
564
+ if (rowId)
565
+ pageBySourceId.set(rowId, row.id);
566
+ }
567
+ report.imported(page.entry.path, row.id);
568
+ }
569
+ databases.push(planned);
570
+ }
571
+ // A row page whose database could not be planned at all has nothing left to belong to.
572
+ for (const { candidate: page, database } of rowPages)
573
+ if (!settled.has(page))
574
+ report.skipped(page.entry.path, `it is a row of ${database.entry.path}, which was not imported`);
575
+ return { databases, extraPages };
576
+ });
577
+ }
578
+ /** Which page a file's page hangs under, given the two conventions a folder can follow. */
579
+ function parentOf(key, isIndex, indexOfFolder, pageOfFolder) {
580
+ const folder = dirnameOf(key);
581
+ // A folder's own index page hangs under the folder above it, not under itself.
582
+ const search = isIndex ? dirnameOf(folder) : folder;
583
+ const index = indexOfFolder.get(search);
584
+ if (index && index.key !== key)
585
+ return index.key;
586
+ const beside = pageOfFolder.get(search);
587
+ if (beside && beside.key !== key)
588
+ return beside.key;
589
+ return null;
590
+ }
591
+ /**
592
+ * A Confluence space export: one rendered `.html` per page, with the tree in each page's breadcrumb.
593
+ *
594
+ * The breadcrumb is the only thing in the export that says what a page's parent is — the filenames
595
+ * are flat and `index.html` lists everything in one alphabetical table. Reading it is what turns a
596
+ * hundred loose pages into the space somebody actually had.
597
+ */
598
+ function planConfluence(entries, report) {
599
+ const candidates = triage(entries, report, (key) => HTML_EXTENSIONS.has(extensionOf(key)) || extensionOf(key) === '.csv', (key) => {
600
+ if (/(^|\/)(styles|images|js)\//i.test(key))
601
+ return 'part of the export’s own styling, not a page';
602
+ if (/(^|\/)attachments\//i.test(key))
603
+ return 'an attachment, which an import cannot yet carry into a page';
604
+ if (basenameOf(key).toLowerCase() === 'index.html')
605
+ return 'the export’s own index of pages; the pages themselves were imported instead';
606
+ return null;
607
+ });
608
+ const drafts = [];
609
+ for (const candidate of candidates.filter((c) => HTML_EXTENSIONS.has(extensionOf(c.key)))) {
610
+ const root = parseHtml(candidate.text);
611
+ const main = findElement(root, (el) => el.attrs.id === 'main-content') ??
612
+ findElement(root, (el) => classesOf(el).includes('wiki-content')) ??
613
+ findElement(root, (el) => el.tag === 'body');
614
+ if (!main) {
615
+ report.failed(candidate.entry.path, 'the file has no page content in it');
616
+ continue;
617
+ }
618
+ const heading = findElement(root, (el) => el.attrs.id === 'title-text') ??
619
+ findElement(root, (el) => el.attrs.id === 'title-heading');
620
+ const titleTag = findElement(root, (el) => el.tag === 'title');
621
+ // Confluence titles its files "Space name : Page name"; the page's own name is the second half.
622
+ const fromTitleTag = titleTag ? (textContent(titleTag).split(' : ').at(-1) ?? '').trim() : '';
623
+ const title = (heading ? textContent(heading).trim() : '') || fromTitleTag || stemOf(candidate.key);
624
+ drafts.push({
625
+ id: uuidv7(),
626
+ key: candidate.key,
627
+ path: candidate.entry.path,
628
+ title: title || 'Untitled',
629
+ doc: htmlToPageDoc(main),
630
+ parentKey: confluenceParent(root, candidate.key),
631
+ sourceId: null,
632
+ });
633
+ }
634
+ return assemble(drafts, report, archiveKeys(entries), (pageByKey) => {
635
+ const databases = [];
636
+ const extraPages = [];
637
+ for (const candidate of candidates.filter((c) => extensionOf(c.key) === '.csv')) {
638
+ const hostPageId = uuidv7();
639
+ const planned = planDatabase(candidate, stemOf(candidate.key) || 'Imported table', hostPageId, report);
640
+ if (!planned)
641
+ continue;
642
+ extraPages.push({
643
+ id: hostPageId,
644
+ key: candidate.key,
645
+ title: stemOf(candidate.key) || 'Imported table',
646
+ parentId: null,
647
+ doc: { type: 'doc', content: [] },
648
+ });
649
+ pageByKey.set(candidate.key, hostPageId);
650
+ databases.push(planned);
651
+ }
652
+ return { databases, extraPages };
653
+ });
654
+ }
655
+ /** The last page named in a Confluence breadcrumb, which is this page's parent. */
656
+ function confluenceParent(root, key) {
657
+ const crumbs = findElement(root, (el) => el.attrs.id === 'breadcrumb-section');
658
+ if (!crumbs)
659
+ return null;
660
+ const hrefs = [];
661
+ const walk = (node) => {
662
+ if (!('tag' in node))
663
+ return;
664
+ if (node.tag === 'a' && node.attrs.href)
665
+ hrefs.push(node.attrs.href);
666
+ for (const child of node.children)
667
+ walk(child);
668
+ };
669
+ walk(crumbs);
670
+ for (const href of hrefs.reverse()) {
671
+ if (!HTML_EXTENSIONS.has(extensionOf(href)))
672
+ continue;
673
+ if (basenameOf(href).toLowerCase() === 'index.html')
674
+ continue;
675
+ const target = archiveTarget(key, href);
676
+ if (target && target !== key)
677
+ return target;
678
+ }
679
+ return null;
680
+ }
681
+ // ------------------------------------------------------------------------------------------------
682
+ // Assembly
683
+ // ------------------------------------------------------------------------------------------------
684
+ /**
685
+ * Drafts into a plan: parents wired, links resolved, and every page reported.
686
+ *
687
+ * The order is the only order that works. Ids exist first, so `pageByKey` is complete before a single
688
+ * link is looked at; databases are planned next, because a link may point at a database's host page;
689
+ * and only then is any document resolved. Doing it in one pass would silently turn every forward
690
+ * link — which is most of them, in a tree written top down — into plain text.
691
+ */
692
+ function assemble(drafts, report, keys, extras) {
693
+ const kept = drafts.slice(0, MAX_PAGES);
694
+ for (const draft of drafts.slice(MAX_PAGES))
695
+ report.skipped(draft.path, `this import already carries ${MAX_PAGES} pages, which is one job's limit`);
696
+ const pageByKey = new Map(kept.map((draft) => [draft.key, draft.id]));
697
+ const pageBySourceId = new Map(kept.filter((draft) => draft.sourceId).map((draft) => [draft.sourceId, draft.id]));
698
+ const { databases, extraPages } = extras(pageByKey, pageBySourceId);
699
+ const byId = new Map([...kept.map((d) => [d.id, d])]);
700
+ const pages = kept.map((draft) => {
701
+ const parentId = draft.parentKey ? (pageByKey.get(draft.parentKey) ?? null) : null;
702
+ return {
703
+ id: draft.id,
704
+ key: draft.key,
705
+ title: draft.title,
706
+ // A parent that is its own descendant would be a tree with no root; the archive cannot express
707
+ // one, but a hand-edited breadcrumb can, so the cycle is broken by hanging the page at the top.
708
+ parentId: parentId && !isAncestor(draft.id, parentId, byId, pageByKey) ? parentId : null,
709
+ doc: draft.doc,
710
+ };
711
+ });
712
+ const all = [...pages, ...extraPages];
713
+ const resolve = (page) => resolveDocument(page.doc, {
714
+ fromKey: page.key,
715
+ fromLabel: `"${page.title}"`,
716
+ pageByKey,
717
+ pageBySourceId,
718
+ keys,
719
+ report,
720
+ });
721
+ for (const page of all)
722
+ page.doc = resolve(page);
723
+ for (const database of databases)
724
+ for (const row of database.rows)
725
+ row.doc = resolveDocument(row.doc, {
726
+ fromKey: `${database.name}/${row.title}`,
727
+ fromLabel: `"${row.title}"`,
728
+ pageByKey,
729
+ pageBySourceId,
730
+ keys,
731
+ report,
732
+ });
733
+ for (const draft of kept)
734
+ report.imported(draft.path, draft.id);
735
+ return { pages: all, databases, report: report.finish() };
736
+ }
737
+ /** Whether following `parentId` upwards ever reaches `id`. */
738
+ function isAncestor(id, parentId, byId, pageByKey) {
739
+ const seen = new Set();
740
+ let at = parentId;
741
+ while (at !== null && !seen.has(at)) {
742
+ if (at === id)
743
+ return true;
744
+ seen.add(at);
745
+ const draft = byId.get(at);
746
+ at = draft?.parentKey ? (pageByKey.get(draft.parentKey) ?? null) : null;
747
+ }
748
+ return false;
749
+ }
750
+ /**
751
+ * The whole archive, read.
752
+ *
753
+ * One entry point rather than three exported readers, so the service never has to know which shape a
754
+ * source has — and so a fourth source is a case here rather than a branch in the job.
755
+ */
756
+ export function planImport(entries, source) {
757
+ const order = new Map(entries.map((entry, index) => [entry.path, index]));
758
+ const report = new Report(order);
759
+ return source === 'confluence' ? planConfluence(entries, report) : planDocuments(entries, source, report);
760
+ }
761
+ //# sourceMappingURL=plan.js.map