docx.mjs
148 lines 4.6 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Import a .docx file into a vault note (Markdown via mammoth).
3 */
4
5 import crypto from 'crypto';
6 import fs from 'fs';
7 import path from 'path';
8 import mammoth from 'mammoth';
9 import { writeNote } from '../write.mjs';
10 import { normalizeSlug } from '../vault.mjs';
11
12 /**
13 * Stable id from file bytes (hex, 32 chars).
14 * @param {Buffer} buf
15 */
16 function sourceIdFromDocxBytes(buf) {
17 return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32);
18 }
19
20 /**
21 * @param {string} inputPath
22 */
23 function titleFromDocxFilename(inputPath) {
24 const base = path.basename(inputPath, path.extname(inputPath));
25 const cleaned = base.replace(/[-_]+/g, ' ').trim();
26 return cleaned || 'Imported DOCX';
27 }
28
29 /**
30 * @param {string} md
31 */
32 function normalizeMarkdownBody(md) {
33 let t = String(md || '').replace(/\r\n/g, '\n');
34 t = t.replace(/\u00a0/g, ' ');
35 t = t.replace(/\n{3,}/g, '\n\n').trim();
36 return t;
37 }
38
39 async function convertDocxBytes(buf) {
40 try {
41 const result = await mammoth.convertToMarkdown({
42 buffer: Buffer.isBuffer(buf) ? buf : Buffer.from(buf),
43 });
44 const markdown = normalizeMarkdownBody(result.value);
45 return markdown
46 ? { ok: true, markdown, messages: result.messages ?? [] }
47 : { ok: false, reason: 'empty_extract' };
48 } catch {
49 return { ok: false, reason: 'empty_extract' };
50 }
51 }
52
53 /**
54 * Convert DOCX bytes to normalized Markdown without writing a note.
55 *
56 * @param {Buffer|Uint8Array} buf
57 * @returns {Promise<{ ok: true, markdown: string } | { ok: false, reason: 'empty_extract' }>}
58 */
59 export async function docxBytesToMarkdown(buf) {
60 const result = await convertDocxBytes(buf);
61 return result.ok
62 ? { ok: true, markdown: result.markdown }
63 : { ok: false, reason: 'empty_extract' };
64 }
65
66 /**
67 * @param {string} input - Path to a .docx file
68 * @param {{
69 * vaultPath: string,
70 * outputBase: string,
71 * project?: string | null,
72 * tags: string[],
73 * dryRun: boolean,
74 * onProgress?: (p: { progress: number, total?: number, message?: string }) => void | Promise<void>
75 * }} ctx
76 * @returns {Promise<{ imported: { path: string, source_id?: string }[], count: number }>}
77 */
78 export async function importDocx(input, ctx) {
79 const raw = typeof input === 'string' ? input.trim() : '';
80 if (!raw) throw new Error('DOCX path is required');
81
82 const { vaultPath, outputBase, project, tags, dryRun, onProgress } = ctx;
83 if (onProgress) await onProgress({ progress: 0, total: 1, message: 'Reading DOCX…' });
84
85 const absInput = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
86 if (!fs.existsSync(absInput)) {
87 throw new Error(`Input not found: ${input}`);
88 }
89 if (!fs.statSync(absInput).isFile()) {
90 throw new Error(`DOCX import requires a single .docx file (not a directory): ${input}`);
91 }
92 if (!absInput.toLowerCase().endsWith('.docx')) {
93 throw new Error(`DOCX import requires a .docx file; got: ${path.basename(absInput)}`);
94 }
95
96 const buf = fs.readFileSync(absInput);
97 const source_id = sourceIdFromDocxBytes(buf);
98 const short = source_id.slice(0, 12);
99 const outputRel = path.join(outputBase, 'imports', 'docx', `${short}.md`).replace(/\\/g, '/');
100
101 const result = await convertDocxBytes(buf);
102 if (!result.ok) {
103 throw new Error('Could not convert this DOCX to usable text (empty document)');
104 }
105 const bodyMd = result.markdown;
106
107 const now = new Date().toISOString().slice(0, 10);
108 const baseName = path.basename(absInput);
109 const title = titleFromDocxFilename(absInput);
110
111 let body =
112 bodyMd +
113 '\n\n---\n\n' +
114 `_Imported from DOCX:_ \`${baseName}\`.\n`;
115
116 if (result.messages && result.messages.length > 0) {
117 const lines = result.messages
118 .map((m) => (m && typeof m.message === 'string' ? m.message.trim() : ''))
119 .filter(Boolean);
120 if (lines.length) {
121 body += '\n_Conversion notes:_\n\n' + lines.map((l) => `- ${l}`).join('\n') + '\n';
122 }
123 }
124
125 const merged = {
126 title,
127 date: now,
128 source: 'docx-import',
129 source_id,
130 docx_file: baseName,
131 ...(project && { project: normalizeSlug(project) }),
132 ...(tags.length && { tags }),
133 };
134 if (typeof merged.tags === 'string') merged.tags = tags;
135 else if (Array.isArray(merged.tags)) merged.tags = [...new Set([...merged.tags, ...tags])];
136 else merged.tags = tags;
137
138 if (!dryRun) {
139 writeNote(vaultPath, outputRel, {
140 body,
141 frontmatter: Object.fromEntries(Object.entries(merged).filter(([, v]) => v !== undefined && v !== null && v !== '')),
142 });
143 }
144
145 if (onProgress) await onProgress({ progress: 1, total: 1, message: 'Done' });
146
147 return { imported: [{ path: outputRel, source_id }], count: 1 };
148 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago