@moxn/kb-migrate 0.4.35 → 0.4.37

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.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Format a failed `/api/v1/kb/*` response body into a single error message.
3
+ *
4
+ * The app's error envelope is `{ error?: string, code?: string, details?: unknown }`.
5
+ * `details` is frequently a structured OBJECT (e.g. a 409's conflicting path),
6
+ * so the old `\`...: ${body.details}\`` interpolation rendered the useless
7
+ * "[object Object]". Prefer the human `error` string; fall back to `details`
8
+ * (JSON-stringified when it's an object); finally just the status.
9
+ */
10
+ export declare function formatApiError(status: number, body: {
11
+ error?: unknown;
12
+ details?: unknown;
13
+ [key: string]: unknown;
14
+ } | null | undefined): string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Format a failed `/api/v1/kb/*` response body into a single error message.
3
+ *
4
+ * The app's error envelope is `{ error?: string, code?: string, details?: unknown }`.
5
+ * `details` is frequently a structured OBJECT (e.g. a 409's conflicting path),
6
+ * so the old `\`...: ${body.details}\`` interpolation rendered the useless
7
+ * "[object Object]". Prefer the human `error` string; fall back to `details`
8
+ * (JSON-stringified when it's an object); finally just the status.
9
+ */
10
+ export function formatApiError(status, body) {
11
+ const detail = typeof body?.error === 'string' && body.error.length > 0
12
+ ? body.error
13
+ : body?.details != null
14
+ ? typeof body.details === 'string'
15
+ ? body.details
16
+ : JSON.stringify(body.details)
17
+ : '';
18
+ return detail ? `API error ${status}: ${detail}` : `API error: ${status}`;
19
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { formatApiError } from './api-error.js';
3
+ describe('formatApiError', () => {
4
+ it('renders a structured `details` object as JSON, not "[object Object]"', () => {
5
+ // The 409 the import path used to surface: { error, code, details: {...} }.
6
+ const msg = formatApiError(409, {
7
+ error: 'Path already exists',
8
+ code: 'CONFLICT',
9
+ details: { path: '/imports/runbook', conflictingId: 'abc' },
10
+ });
11
+ // Prefer the human message; never leak "[object Object]".
12
+ expect(msg).not.toContain('[object Object]');
13
+ expect(msg).toContain('409');
14
+ expect(msg).toContain('Path already exists');
15
+ });
16
+ it('falls back to a JSON-stringified details when there is no error string', () => {
17
+ const msg = formatApiError(400, { details: { reason: 'bad input' } });
18
+ expect(msg).toBe('API error 400: {"reason":"bad input"}');
19
+ });
20
+ it('uses a plain string detail as-is', () => {
21
+ expect(formatApiError(500, { details: 'boom' })).toBe('API error 500: boom');
22
+ });
23
+ it('degrades to a bare status when the body carries no error/details', () => {
24
+ expect(formatApiError(503, {})).toBe('API error: 503');
25
+ });
26
+ });
package/dist/client.d.ts CHANGED
@@ -3,6 +3,31 @@
3
3
  */
4
4
  import type { ExtractedDocument, MigrationOptions, MigrationResult, DocumentListItem, DocumentDetail, ExportOptions } from './types.js';
5
5
  import type { DateFilter } from './date-filter.js';
6
+ /**
7
+ * Grammar-markdown export of a document (`get_document_markdown` action on
8
+ * /api/v1/kb/export). `markdown` is the moxn-grammar interchange: front-matter
9
+ * + body + `:::image/:::csv/:::file/:::db` embeds with STORAGE refs.
10
+ */
11
+ export interface DocumentMarkdownResponse {
12
+ id: string;
13
+ branchName: string;
14
+ markdown: string;
15
+ }
16
+ /**
17
+ * Result of an `import_markdown` UPSERT (action on /api/v1/kb/import).
18
+ * Mirrors the server's KBDocumentImportMarkdownResult.
19
+ */
20
+ export interface ImportMarkdownResult {
21
+ id: string;
22
+ /** The CANONICAL path the doc landed at (`<parent>/<slug(name)>`), not the requested path. */
23
+ path: string;
24
+ outcome: 'created' | 'updated' | 'skipped';
25
+ existed: boolean;
26
+ /** The resolved document name (front-matter name, first H1, or filename stem). */
27
+ name?: string;
28
+ /** True when a front-matter block was present but NOT applied (no name → body-only import). */
29
+ frontMatterIgnored?: boolean;
30
+ }
6
31
  export declare class MoxnClient {
7
32
  private apiUrl;
8
33
  private apiKey;
@@ -22,6 +47,42 @@ export declare class MoxnClient {
22
47
  * Get full document detail with sections and content.
23
48
  */
24
49
  getDocument(documentId: string): Promise<DocumentDetail>;
50
+ /**
51
+ * Get a document's GRAMMAR markdown (front-matter + body + `:::image/:::csv/
52
+ * :::file/:::db` embeds with STORAGE refs) via the `get_document_markdown`
53
+ * export action. This is the grammar interchange the local file-adapter
54
+ * writes to disk (Phase 9a-3) — the export IS the MCP `read`.
55
+ *
56
+ * `forEdit: true` keeps the H2 `{#id}` section anchors so a re-import can
57
+ * address sections stably.
58
+ */
59
+ getDocumentMarkdown(documentId: string, opts?: {
60
+ forEdit?: boolean;
61
+ }): Promise<DocumentMarkdownResponse | null>;
62
+ /**
63
+ * Build a `storageKey → signedDownloadUrl` map for a document's media, by
64
+ * reusing the `get_document_content` export action (the blocks read, which
65
+ * resolves each storage ref to a fresh signed URL). The grammar markdown
66
+ * carries storage KEYS in its embed `ref`s — this is how the file-adapter
67
+ * turns a key into a downloadable URL WITHOUT a new server endpoint.
68
+ * Returns an empty map when the document has no resolvable media.
69
+ */
70
+ getDocumentMediaUrls(documentId: string): Promise<Map<string, string>>;
71
+ /**
72
+ * UPSERT a document from GRAMMAR markdown via the `import_markdown` action.
73
+ * The inverse of {@link getDocumentMarkdown}: front-matter → name/path/tags/
74
+ * refs/properties, body → sections, all via the shipped `replace_document`
75
+ * verb. Media refs in the markdown must already be valid STORAGE keys (the
76
+ * file-adapter uploads local files + rewrites refs BEFORE calling this).
77
+ *
78
+ * onConflict 'skip' → skip if the path exists
79
+ * onConflict 'update' → replace_document (full-set PUT)
80
+ */
81
+ importMarkdown(input: {
82
+ markdown: string;
83
+ path?: string;
84
+ onConflict: 'skip' | 'update';
85
+ }): Promise<ImportMarkdownResult>;
25
86
  /**
26
87
  * Download a file from a URL to a local path.
27
88
  */
package/dist/client.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * API client for Moxn KB
3
3
  */
4
4
  import * as fs from 'fs/promises';
5
+ import { formatApiError } from './api-error.js';
5
6
  export class MoxnClient {
6
7
  apiUrl;
7
8
  apiKey;
@@ -166,6 +167,101 @@ export class MoxnClient {
166
167
  }
167
168
  return response.json();
168
169
  }
170
+ /**
171
+ * Get a document's GRAMMAR markdown (front-matter + body + `:::image/:::csv/
172
+ * :::file/:::db` embeds with STORAGE refs) via the `get_document_markdown`
173
+ * export action. This is the grammar interchange the local file-adapter
174
+ * writes to disk (Phase 9a-3) — the export IS the MCP `read`.
175
+ *
176
+ * `forEdit: true` keeps the H2 `{#id}` section anchors so a re-import can
177
+ * address sections stably.
178
+ */
179
+ async getDocumentMarkdown(documentId, opts) {
180
+ const response = await fetch(`${this.apiUrl}/api/v1/kb/export`, {
181
+ method: 'POST',
182
+ headers: {
183
+ 'Content-Type': 'application/json',
184
+ 'x-api-key': this.apiKey,
185
+ },
186
+ body: JSON.stringify({
187
+ action: 'get_document_markdown',
188
+ documentId,
189
+ forEdit: opts?.forEdit ?? false,
190
+ }),
191
+ });
192
+ if (!response.ok) {
193
+ const body = await response.text();
194
+ throw new Error(`Failed to get document markdown ${documentId}: ${response.status} ${body}`);
195
+ }
196
+ const data = await response.json();
197
+ return (data.result?.document ?? null);
198
+ }
199
+ /**
200
+ * Build a `storageKey → signedDownloadUrl` map for a document's media, by
201
+ * reusing the `get_document_content` export action (the blocks read, which
202
+ * resolves each storage ref to a fresh signed URL). The grammar markdown
203
+ * carries storage KEYS in its embed `ref`s — this is how the file-adapter
204
+ * turns a key into a downloadable URL WITHOUT a new server endpoint.
205
+ * Returns an empty map when the document has no resolvable media.
206
+ */
207
+ async getDocumentMediaUrls(documentId) {
208
+ const response = await fetch(`${this.apiUrl}/api/v1/kb/export`, {
209
+ method: 'POST',
210
+ headers: {
211
+ 'Content-Type': 'application/json',
212
+ 'x-api-key': this.apiKey,
213
+ },
214
+ body: JSON.stringify({ action: 'get_document_content', documentId }),
215
+ });
216
+ if (!response.ok) {
217
+ const body = await response.text();
218
+ throw new Error(`Failed to get document content ${documentId}: ${response.status} ${body}`);
219
+ }
220
+ const data = await response.json();
221
+ const doc = data.result?.document;
222
+ const map = new Map();
223
+ if (!doc)
224
+ return map;
225
+ for (const section of doc.sections) {
226
+ for (const block of section.content) {
227
+ if (block.storageKey && block.url && !block.url.startsWith('data:')) {
228
+ map.set(block.storageKey, block.url);
229
+ }
230
+ }
231
+ }
232
+ return map;
233
+ }
234
+ /**
235
+ * UPSERT a document from GRAMMAR markdown via the `import_markdown` action.
236
+ * The inverse of {@link getDocumentMarkdown}: front-matter → name/path/tags/
237
+ * refs/properties, body → sections, all via the shipped `replace_document`
238
+ * verb. Media refs in the markdown must already be valid STORAGE keys (the
239
+ * file-adapter uploads local files + rewrites refs BEFORE calling this).
240
+ *
241
+ * onConflict 'skip' → skip if the path exists
242
+ * onConflict 'update' → replace_document (full-set PUT)
243
+ */
244
+ async importMarkdown(input) {
245
+ const response = await fetch(`${this.apiUrl}/api/v1/kb/import`, {
246
+ method: 'POST',
247
+ headers: {
248
+ 'Content-Type': 'application/json',
249
+ 'x-api-key': this.apiKey,
250
+ },
251
+ body: JSON.stringify({
252
+ action: 'import_markdown',
253
+ markdown: input.markdown,
254
+ path: input.path,
255
+ onConflict: input.onConflict,
256
+ }),
257
+ });
258
+ if (!response.ok) {
259
+ const body = await response.json().catch(() => ({}));
260
+ throw new Error(formatApiError(response.status, body));
261
+ }
262
+ const data = await response.json();
263
+ return data.result;
264
+ }
169
265
  /**
170
266
  * Download a file from a URL to a local path.
171
267
  */
@@ -299,12 +395,7 @@ export class MoxnClient {
299
395
  error.branchId = body.branchId;
300
396
  throw error;
301
397
  }
302
- const message = body.details
303
- ? `API error ${response.status}: ${body.details}`
304
- : body.error
305
- ? `API error ${response.status}: ${body.error}`
306
- : `API error: ${response.status}`;
307
- throw new Error(message);
398
+ throw new Error(formatApiError(response.status, body));
308
399
  }
309
400
  return response.json();
310
401
  }
@@ -319,12 +410,7 @@ export class MoxnClient {
319
410
  });
320
411
  if (!response.ok) {
321
412
  const body = await response.json().catch(() => ({}));
322
- const message = body.details
323
- ? `API error ${response.status}: ${body.details}`
324
- : body.error
325
- ? `API error ${response.status}: ${body.error}`
326
- : `API error: ${response.status}`;
327
- throw new Error(message);
413
+ throw new Error(formatApiError(response.status, body));
328
414
  }
329
415
  return response.json();
330
416
  }
package/dist/export.d.ts CHANGED
@@ -1,8 +1,16 @@
1
1
  /**
2
- * Export runner for Moxn KB
2
+ * Export runner for Moxn KB (Phase 9a-3 — grammar markdown).
3
3
  *
4
- * Exports documents from Moxn Knowledge Base to local markdown files
5
- * with downloaded media assets (images, PDFs, CSVs).
4
+ * Exports documents from Moxn Knowledge Base to local GRAMMAR markdown files
5
+ * (front-matter + body + `:::image/:::csv/:::file/:::db` embeds), with
6
+ * downloaded media assets and embed `ref`s rewritten to RELATIVE paths so the
7
+ * exported folder is self-contained and re-imports identically.
8
+ *
9
+ * This is the inverse of the local import (`runMigration` over the LocalSource):
10
+ * - export: `get_document_markdown` → write `.md`; download media; rewrite
11
+ * `:::…{ref="<storage-key>"}` → `ref="<relative-path>"`.
12
+ * - import: read `.md`; upload media; rewrite `ref` back to a storage key;
13
+ * `import_markdown`.
6
14
  */
7
15
  import type { ExportOptions, ExportLog } from './types.js';
8
16
  export declare function runExport(outputDir: string, options: ExportOptions): Promise<ExportLog>;
package/dist/export.js CHANGED
@@ -1,140 +1,51 @@
1
1
  /**
2
- * Export runner for Moxn KB
2
+ * Export runner for Moxn KB (Phase 9a-3 — grammar markdown).
3
3
  *
4
- * Exports documents from Moxn Knowledge Base to local markdown files
5
- * with downloaded media assets (images, PDFs, CSVs).
4
+ * Exports documents from Moxn Knowledge Base to local GRAMMAR markdown files
5
+ * (front-matter + body + `:::image/:::csv/:::file/:::db` embeds), with
6
+ * downloaded media assets and embed `ref`s rewritten to RELATIVE paths so the
7
+ * exported folder is self-contained and re-imports identically.
8
+ *
9
+ * This is the inverse of the local import (`runMigration` over the LocalSource):
10
+ * - export: `get_document_markdown` → write `.md`; download media; rewrite
11
+ * `:::…{ref="<storage-key>"}` → `ref="<relative-path>"`.
12
+ * - import: read `.md`; upload media; rewrite `ref` back to a storage key;
13
+ * `import_markdown`.
6
14
  */
7
15
  import * as fs from 'fs';
8
16
  import * as path from 'path';
9
17
  import { MoxnClient } from './client.js';
10
18
  import { matchesDateFilter } from './date-filter.js';
19
+ import { extractMediaRefs, rewriteMediaRefs, } from './grammar-media.js';
11
20
  // ──────────────────────────────────────────────
12
21
  // Utility functions
13
22
  // ──────────────────────────────────────────────
14
- /** Strip <moxn:comment> tags from text, preserving the inner text. */
15
- function stripCommentTags(text) {
16
- return text.replace(/<moxn:comment[^>]*>([\s\S]*?)<\/moxn:comment>/g, '$1');
17
- }
18
- /** Derive a local filename from a storage key or URL. */
19
- function deriveFilename(storageKey, url, fallbackExt) {
20
- if (storageKey) {
21
- const parts = storageKey.split('/');
22
- return parts[parts.length - 1];
23
- }
24
- try {
25
- const urlPath = new URL(url).pathname;
26
- const basename = urlPath.split('/').pop();
27
- if (basename && basename.includes('.')) {
28
- return basename;
29
- }
30
- }
31
- catch {
32
- // Not a valid URL
23
+ /** Derive a local filename from a storage key (the key's basename). */
24
+ function filenameFromStorageKey(storageKey, fallbackExt) {
25
+ const basename = storageKey.split('/').pop();
26
+ if (basename && basename.length > 0) {
27
+ return basename.includes('.') ? basename : `${basename}${fallbackExt}`;
33
28
  }
34
29
  return `export-${Date.now()}${fallbackExt}`;
35
30
  }
36
- /** Get file extension from MIME type. */
37
- function mimeToExt(mimeType) {
38
- const map = {
39
- 'image/png': '.png',
40
- 'image/jpeg': '.jpg',
41
- 'image/gif': '.gif',
42
- 'image/webp': '.webp',
43
- 'application/pdf': '.pdf',
44
- 'text/csv': '.csv',
45
- };
46
- return map[mimeType] || '';
31
+ /** Map an embed directive to its output media dir + a fallback extension. */
32
+ function mediaTargetForDirective(directive, options) {
33
+ switch (directive) {
34
+ case 'image':
35
+ return { dir: options.imageDir, fallbackExt: '' };
36
+ case 'csv':
37
+ return { dir: options.csvDir, fallbackExt: '.csv' };
38
+ case 'file':
39
+ default:
40
+ // `file` embeds are generic attachments (PDFs and everything else).
41
+ return { dir: options.pdfDir, fallbackExt: '' };
42
+ }
47
43
  }
48
- /** Convert a document path to a local file path. */
44
+ /** Convert a KB document path to a local `.md` file path. */
49
45
  function docPathToFilePath(docPath) {
50
46
  return `${docPath.replace(/^\//, '')}.md`;
51
47
  }
52
48
  // ──────────────────────────────────────────────
53
- // Markdown builder
54
- // ──────────────────────────────────────────────
55
- function buildMarkdown(doc, mediaMap, mdFilePath) {
56
- const lines = [];
57
- lines.push(`# ${doc.name}`);
58
- lines.push('');
59
- if (doc.description) {
60
- lines.push(doc.description);
61
- lines.push('');
62
- }
63
- for (const section of doc.sections) {
64
- lines.push(`## ${section.name}`);
65
- lines.push('');
66
- for (const block of section.content) {
67
- if (block.blockType === 'text' && block.text) {
68
- lines.push(stripCommentTags(block.text));
69
- lines.push('');
70
- }
71
- else if (block.blockType === 'image' && block.url) {
72
- const key = block.storageKey || block.url;
73
- const localPath = mediaMap.get(key);
74
- if (localPath) {
75
- const relativePath = path.relative(path.dirname(mdFilePath), localPath);
76
- lines.push(`![${block.alt || ''}](${relativePath})`);
77
- }
78
- else {
79
- lines.push(`![${block.alt || ''}](${block.url})`);
80
- }
81
- lines.push('');
82
- }
83
- else if (block.blockType === 'document' && block.url) {
84
- const key = block.storageKey || block.url;
85
- const localPath = mediaMap.get(key);
86
- if (localPath) {
87
- const relativePath = path.relative(path.dirname(mdFilePath), localPath);
88
- lines.push(`[${block.filename || 'document'}](${relativePath})`);
89
- }
90
- else {
91
- lines.push(`[${block.filename || 'document'}](${block.url})`);
92
- }
93
- lines.push('');
94
- }
95
- else if (block.blockType === 'csv' && block.url) {
96
- const key = block.storageKey || block.url;
97
- const localPath = mediaMap.get(key);
98
- if (localPath) {
99
- const relativePath = path.relative(path.dirname(mdFilePath), localPath);
100
- lines.push(`[${block.filename || 'data.csv'}](${relativePath})`);
101
- }
102
- else {
103
- lines.push(`[${block.filename || 'data.csv'}](${block.url})`);
104
- }
105
- lines.push('');
106
- }
107
- }
108
- }
109
- // Trim trailing blank lines, end with single newline
110
- while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
111
- lines.pop();
112
- }
113
- lines.push('');
114
- return lines.join('\n');
115
- }
116
- // ──────────────────────────────────────────────
117
- // Media helpers
118
- // ──────────────────────────────────────────────
119
- function getMediaTarget(block, options) {
120
- if (block.blockType === 'image') {
121
- return {
122
- targetDir: options.imageDir,
123
- fallbackExt: mimeToExt(block.mimeType || 'image/png'),
124
- };
125
- }
126
- else if (block.blockType === 'document') {
127
- return {
128
- targetDir: options.pdfDir,
129
- fallbackExt: mimeToExt(block.mimeType || 'application/pdf'),
130
- };
131
- }
132
- else if (block.blockType === 'csv') {
133
- return { targetDir: options.csvDir, fallbackExt: '.csv' };
134
- }
135
- return null;
136
- }
137
- // ──────────────────────────────────────────────
138
49
  // Main export runner
139
50
  // ──────────────────────────────────────────────
140
51
  export async function runExport(outputDir, options) {
@@ -192,64 +103,90 @@ export async function runExport(outputDir, options) {
192
103
  const mediaFiles = [];
193
104
  console.error(`Processing: ${docItem.path}`);
194
105
  try {
195
- const doc = await client.getDocument(docItem.id);
196
- const mdRelativePath = docPathToFilePath(doc.path);
106
+ // 1. Grammar markdown body (front-matter + body + embeds with storage refs).
107
+ // CLEAN export (no `forEdit`): the H2 `{#id}` section anchors are
108
+ // DROPPED. Those ids belong to THIS source document; the local import
109
+ // feeds the grammar straight to `replace_document`, which on a re-home
110
+ // (import to a NEW path / new doc) REJECTS foreign ids
111
+ // (`section id … not found on branch main`). `replace_document`'s
112
+ // diff-derived identity already preserves ids on a same-path UPDATE by
113
+ // content-matching, so the anchors buy nothing there and only break the
114
+ // CREATE/re-home case. Matches the `/api/v1/kb/export` default contract
115
+ // and the kb-import-markdown round-trip test (both use the clean export).
116
+ const doc = await client.getDocumentMarkdown(docItem.id);
117
+ if (!doc) {
118
+ throw new Error('document is not a grammar-markdown document (no body)');
119
+ }
120
+ const mdRelativePath = docPathToFilePath(docItem.path);
197
121
  const mdFullPath = path.join(outputDir, mdRelativePath);
198
- const mediaMap = new Map();
199
- // Download media
200
- for (const section of doc.sections) {
201
- for (const block of section.content) {
202
- if (block.blockType === 'text')
203
- continue;
204
- if (!block.url)
205
- continue;
206
- if (block.url.startsWith('data:'))
207
- continue;
208
- const target = getMediaTarget(block, options);
209
- if (!target)
210
- continue;
211
- const filename = deriveFilename(block.storageKey, block.url, target.fallbackExt);
212
- const localRelativePath = path.join(target.targetDir, filename);
213
- const localFullPath = path.join(outputDir, localRelativePath);
214
- const mapKey = block.storageKey || block.url;
215
- mediaMap.set(mapKey, path.join(outputDir, localRelativePath));
216
- if (!options.dryRun) {
217
- if (!fs.existsSync(localFullPath)) {
218
- try {
219
- await client.downloadFile(block.url, localFullPath);
220
- totalMediaDownloaded++;
221
- }
222
- catch (err) {
223
- const msg = err instanceof Error ? err.message : String(err);
224
- console.error(` \u2717 Download failed: ${filename}: ${msg}`);
225
- }
122
+ // 2. Discover the media storage keys referenced in the body.
123
+ const mediaRefs = extractMediaRefs(doc.markdown);
124
+ // 3. Resolve storage keys → signed download URLs (reuses the blocks read).
125
+ // Only fetch when there's media to download.
126
+ const signedUrls = mediaRefs.length
127
+ ? await client.getDocumentMediaUrls(docItem.id)
128
+ : new Map();
129
+ // 4. Download each unique media key + build a key → relative-path map.
130
+ const keyToRelPath = new Map();
131
+ for (const { directive, ref } of mediaRefs) {
132
+ // Skip non-storage refs (e.g. an external URL someone hand-authored)
133
+ // and refs we've already handled.
134
+ if (keyToRelPath.has(ref))
135
+ continue;
136
+ const signedUrl = signedUrls.get(ref);
137
+ if (!signedUrl) {
138
+ // No signed URL for this ref — leave it as-is (rewrite skips null).
139
+ continue;
140
+ }
141
+ const target = mediaTargetForDirective(directive, options);
142
+ const filename = filenameFromStorageKey(ref, target.fallbackExt);
143
+ const localRelativePath = path.join(target.dir, filename);
144
+ const localFullPath = path.join(outputDir, localRelativePath);
145
+ if (!options.dryRun) {
146
+ if (!fs.existsSync(localFullPath)) {
147
+ try {
148
+ await client.downloadFile(signedUrl, localFullPath);
149
+ totalMediaDownloaded++;
150
+ }
151
+ catch (err) {
152
+ const msg = err instanceof Error ? err.message : String(err);
153
+ console.error(` ✗ Download failed: ${filename}: ${msg}`);
154
+ // Leave the embed ref pointing at the storage key (no rewrite).
155
+ continue;
226
156
  }
227
157
  }
228
- else {
229
- totalMediaDownloaded++;
230
- }
231
- mediaFiles.push(localRelativePath);
232
158
  }
159
+ else {
160
+ totalMediaDownloaded++;
161
+ }
162
+ // The embed ref is rewritten to a path RELATIVE to the .md file.
163
+ const refRelativeToMd = path.relative(path.dirname(mdFullPath), localFullPath);
164
+ keyToRelPath.set(ref, refRelativeToMd);
165
+ mediaFiles.push(localRelativePath);
233
166
  }
234
- const markdown = buildMarkdown(doc, mediaMap, mdFullPath);
167
+ // 5. Rewrite the embed refs (storage key → relative path). Refs with no
168
+ // download (keyToRelPath miss) are left untouched.
169
+ const markdown = rewriteMediaRefs(doc.markdown, (ref) => keyToRelPath.get(ref) ?? null);
235
170
  if (!options.dryRun) {
236
171
  fs.mkdirSync(path.dirname(mdFullPath), { recursive: true });
237
172
  fs.writeFileSync(mdFullPath, markdown, 'utf-8');
238
173
  }
239
- console.error(` \u2713 ${mdRelativePath} (${doc.sections.length} sections, ${mediaFiles.length} media)`);
174
+ console.error(` ✓ ${mdRelativePath} (${mediaFiles.length} media)`);
240
175
  results.push({
241
- documentId: doc.id,
242
- documentPath: doc.path,
176
+ documentId: docItem.id,
177
+ documentPath: docItem.path,
243
178
  outputFile: mdRelativePath,
244
179
  status: 'exported',
245
- sectionsCount: doc.sections.length,
180
+ // Grammar export is body-grain, not section-grain; sections are no
181
+ // longer enumerated client-side.
182
+ sectionsCount: 0,
246
183
  mediaFiles,
247
184
  duration: Date.now() - docStart,
248
185
  });
249
186
  }
250
187
  catch (error) {
251
188
  const message = error instanceof Error ? error.message : String(error);
252
- console.error(` \u2717 Failed: ${message}`);
189
+ console.error(` ✗ Failed: ${message}`);
253
190
  results.push({
254
191
  documentId: docItem.id,
255
192
  documentPath: docItem.path,
@@ -0,0 +1 @@
1
+ export {};