superwiki 0.1.2 → 0.1.4

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.
@@ -1,225 +1,467 @@
1
1
  #!/usr/bin/env node
2
2
  // Converts a table-based task index into Superwiki task files, driven by a mapping file.
3
3
  // Bulk, mechanical work only: what a table row and a detail section say goes into one task file,
4
- // links to tasks become wikilinks, consumed files move to an archive folder. Nothing is deleted.
5
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, renameSync, statSync } from 'node:fs';
6
- import { dirname, join, relative, resolve, posix } from 'node:path';
4
+ // links to tasks become wikilinks, and the files that were consumed move to an archive folder.
5
+ // Nothing is deleted.
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join, posix, resolve } from 'node:path';
7
8
 
8
- const HELP = `migrate.mjs --mapping <file.json> [--docs <dir>] [--dry-run]
9
+ const HELP = `migrate.mjs --inspect [--docs <dir>]
10
+ migrate.mjs --mapping <file.json> [--docs <dir>] [--dry-run]
11
+
12
+ --inspect prints the tables, columns, status values and task headings found under the docs
13
+ folder, so the mapping can be written without reading the files.
9
14
 
10
15
  Mapping (paths relative to the docs folder):
11
16
  {
12
17
  "index": "index.md", file holding the task tables
13
18
  "columns": { "id": "ID", "title": "Task", "status": "Status", "deps": "Depends on",
14
19
  "milestone": "Target", "priority": "Order", "started": "Start", "finished": "End" },
15
- "sections": { "Sources": "Source", "Notes": "Note" }, table columns kept as body sections
20
+ "fields": { "review": "Review" }, other columns kept as frontmatter fields
21
+ "sections": { "Sources": "Source", "Notes": "Note" }, other columns kept as body sections
16
22
  "status": { "Not started": "todo", "In progress": "in-progress", "Done": "done" },
17
23
  "softPrefix": "~", marks a soft dependency in the deps column
18
24
  "details": { "dir": "tasks", "heading": "## " }, per-task detail sections: "<heading><ID>"
19
25
  "log": { "file": "changelog.md", "columns": { "date": "Date", "id": "Task", "text": "Change" } },
20
- "archive": "legacy" consumed files are moved here
21
- }
22
- Only "index", "columns.id", "columns.title", "columns.status" and "status" are required.`;
23
-
24
- const argv = process.argv.slice(2);
25
- const opt = n => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; };
26
- if (argv.includes('--help') || !opt('--mapping')) { console.log(HELP); process.exit(argv.includes('--help') ? 0 : 2); }
27
- const dry = argv.includes('--dry-run');
28
- const docs = resolve(opt('--docs') || 'docs');
29
- const map = JSON.parse(readFileSync(resolve(opt('--mapping')), 'utf8'));
30
- const archive = map.archive || 'legacy';
31
- const soft = map.softPrefix ?? '~';
26
+ "archiveAlso": ["plans/old"], other files or folders to move to the archive
27
+ "archive": "legacy" where consumed files go
28
+ }
29
+ Required: "index", "columns.id", "columns.title", "columns.status", "status".
30
+ Status keys are written without decoration: a cell showing "✅ Done" is matched by "Done".`;
31
+
32
32
  const ID = /[A-Z][A-Z0-9]*-\d+/;
33
- const fail = msg => { console.error(msg); process.exit(2); };
33
+ const VAULT_FOLDERS = ['wiki', 'tasks', 'plans'];
34
+ const CORE_FIELDS = ['type', 'id', 'title', 'status', 'deps', 'soft_deps', 'milestone', 'priority', 'started', 'finished'];
35
+ const MARKDOWN_LINK = /\[([^\[\]]*)\]\(([^)\s]+)\)( ✅)?/g;
36
+ const LIST_LIMIT = 10;
34
37
 
35
- const indexPath = join(docs, map.index);
36
- if (!existsSync(indexPath)) fail(`no ${map.index} in ${docs}`);
37
- // Empty folders left by an aborted run are not a vault and not an archive.
38
- const hasFiles = d => existsSync(d) && readdirSync(d, { recursive: true, withFileTypes: true }).some(e => e.isFile());
39
- if (existsSync(join(docs, '.sw', 'config.json'))) fail('docs/.sw/config.json exists: this folder is already a Superwiki vault');
40
- if (hasFiles(join(docs, archive))) fail(`docs/${archive} has files in it: choose another "archive" name or remove it`);
38
+ class UsageError extends Error {}
41
39
 
42
- // ---------- Markdown tables ----------
43
- const splitRow = line => line.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'));
40
+ // ---------- Markdown ----------
41
+
42
+ const splitRow = line =>
43
+ line.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(cell => cell.trim().replace(/\\\|/g, '|'));
44
+
45
+ // Every table in the text as { line, head, rows }, each row an object keyed by column name.
44
46
  function tables(text) {
45
47
  const lines = text.split(/\r?\n/);
46
- const out = [];
48
+ const found = [];
47
49
  for (let i = 0; i + 1 < lines.length; i++) {
48
- if (!lines[i].trim().startsWith('|') || !/^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1])) continue;
50
+ const isHeader = lines[i].trim().startsWith('|') && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1]);
51
+ if (!isHeader) continue;
49
52
  const head = splitRow(lines[i]);
50
53
  const rows = [];
51
54
  let j = i + 2;
52
55
  for (; j < lines.length && lines[j].trim().startsWith('|'); j++) {
53
56
  const cells = splitRow(lines[j]);
54
- rows.push(Object.fromEntries(head.map((h, k) => [h, cells[k] ?? ''])));
57
+ rows.push(Object.fromEntries(head.map((name, k) => [name, cells[k] ?? ''])));
55
58
  }
56
- out.push({ head, rows });
59
+ found.push({ line: i + 1, head, rows });
57
60
  i = j - 1;
58
61
  }
59
- return out;
62
+ return found;
63
+ }
64
+
65
+ // A cell as a reader sees it: no HTML, link labels instead of links, no emphasis marks.
66
+ const plain = cell =>
67
+ cell.replace(/<[^>]+>/g, '').replace(/\[([^\[\]]*)\]\([^)]*\)/g, '$1').replace(/[`*]/g, '').trim();
68
+
69
+ // A status cell without the check mark some indexes decorate finished rows with.
70
+ const statusText = cell => plain(cell).replace(/✅/g, '').trim();
71
+
72
+ // Single-quoted YAML: its only escape is a doubled quote, so values with " or \ survive as written.
73
+ const yaml = value => (/^[\w./-]*$/.test(value) ? value : `'${value.replace(/'/g, "''")}'`);
74
+
75
+ // ---------- Files ----------
76
+
77
+ // Every file under dir as a path relative to it, skipping dot entries and node_modules.
78
+ function walk(dir, rel = '') {
79
+ const files = [];
80
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
81
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
82
+ const path = rel ? `${rel}/${entry.name}` : entry.name;
83
+ if (entry.isDirectory()) files.push(...walk(join(dir, entry.name), path));
84
+ else files.push(path);
85
+ }
86
+ return files;
87
+ }
88
+
89
+ const isMarkdown = path => path.endsWith('.md');
90
+ const read = (docs, path) => readFileSync(join(docs, path), 'utf8');
91
+
92
+ // ---------- Inspect ----------
93
+
94
+ // A table is about tasks when one of its columns holds a task id in most rows.
95
+ function taskColumn(table) {
96
+ return table.head.find(name => table.rows.filter(row => ID.test(plain(row[name]))).length >= table.rows.length * 0.7);
97
+ }
98
+
99
+ // The distinct values of each column that has only a few, with their counts: status-like columns.
100
+ function fewValuedColumns(table) {
101
+ const lines = [];
102
+ for (const name of table.head) {
103
+ const counts = new Map();
104
+ for (const row of table.rows) {
105
+ const value = statusText(row[name]);
106
+ counts.set(value, (counts.get(value) || 0) + 1);
107
+ }
108
+ const values = [...counts.keys()].filter(Boolean);
109
+ const words = values.some(value => !/^[\d.\-—–]+$/.test(value)); // not just dates, numbers or dashes
110
+ const repeats = Math.max(...counts.values()) >= 2;
111
+ const statusLike = counts.size >= 2 && counts.size <= 6 && words && repeats && values.every(value => value.length <= 24);
112
+ if (statusLike) lines.push(` values of "${name}": ${[...counts].map(([value, n]) => `${value || '(empty)'} ${n}`).join(', ')}`);
113
+ }
114
+ return lines;
115
+ }
116
+
117
+ // Prints what a mapping needs to know: the tables that list tasks, their columns, the values of
118
+ // status-like columns, and the files that hold per-task headings. Other tables are only counted.
119
+ function inspect(docs) {
120
+ const out = [];
121
+ let otherTables = 0;
122
+ for (const path of walk(docs).filter(isMarkdown)) {
123
+ if (statSync(join(docs, path)).size > 5e6) continue;
124
+ const text = read(docs, path);
125
+ const all = tables(text).filter(table => table.rows.length >= 3);
126
+ const taskTables = all.filter(taskColumn);
127
+ otherTables += all.length - taskTables.length;
128
+ const headings = text.split(/\r?\n/).filter(line => new RegExp(`^#{1,4} ${ID.source}\\s*$`).test(line));
129
+ if (!taskTables.length && headings.length < 2) continue;
130
+ out.push(`${path} (${Math.round(text.length / 1024)} KB)`);
131
+ for (const table of taskTables) {
132
+ out.push(` line ${table.line}: ${table.rows.length} rows, ids in "${taskColumn(table)}" | ${table.head.join(' | ')}`);
133
+ if (table.rows.length >= 5) out.push(...fewValuedColumns(table));
134
+ }
135
+ if (headings.length >= 2) {
136
+ const mark = headings[0].match(/^#+ /)[0];
137
+ out.push(` ${headings.length} task headings like "${headings[0]}" (details heading: "${mark}")`);
138
+ }
139
+ }
140
+ if (otherTables) out.push(`${otherTables} other tables without task ids are not shown`);
141
+ for (const folder of VAULT_FOLDERS) {
142
+ if (!existsSync(join(docs, folder))) continue;
143
+ out.push(`note: docs/${folder}/ exists (${walk(join(docs, folder)).length} files). Superwiki uses that folder: what the mapping does not consume must go into "archiveAlso".`);
144
+ }
145
+ console.log(out.length ? out.join('\n') : 'no task tables or task headings found');
60
146
  }
61
147
 
62
- // ---------- Links ----------
63
- const consumed = new Set([map.index]); // files whose task anchors turn into wikilinks
64
- const detailDir = map.details?.dir;
65
- if (detailDir && existsSync(join(docs, detailDir))) {
66
- for (const n of readdirSync(join(docs, detailDir))) if (n.endsWith('.md')) consumed.add(posix.join(detailDir, n));
148
+ // ---------- Conversion ----------
149
+
150
+ function readMapping(path) {
151
+ const map = JSON.parse(readFileSync(path, 'utf8'));
152
+ const missing = ['index', 'status'].filter(key => !map[key]).concat(['id', 'title', 'status'].filter(key => !map.columns?.[key]).map(key => `columns.${key}`));
153
+ if (missing.length) throw new UsageError(`the mapping lacks: ${missing.join(', ')}`);
154
+ const reserved = Object.keys(map.fields || {}).filter(name => CORE_FIELDS.includes(name));
155
+ if (reserved.length) throw new UsageError(`"fields" may not redefine: ${reserved.join(', ')}`);
156
+ return { archive: 'legacy', softPrefix: '~', fields: {}, sections: {}, archiveAlso: [], ...map };
157
+ }
158
+
159
+ // The files that leave their place: the index, the detail files, the log source and anything
160
+ // named in archiveAlso. Links into them are re-pointed, and the files themselves are archived.
161
+ function filesToArchive(docs, map, problems) {
162
+ const moved = new Set([map.index]);
163
+ if (map.log?.file) moved.add(map.log.file);
164
+ const detailDir = map.details?.dir;
165
+ if (detailDir && existsSync(join(docs, detailDir))) {
166
+ for (const name of readdirSync(join(docs, detailDir))) if (isMarkdown(name)) moved.add(posix.join(detailDir, name));
167
+ }
168
+ for (const path of map.archiveAlso) {
169
+ const full = join(docs, path);
170
+ if (!existsSync(full)) problems.push(`archiveAlso: docs/${path} does not exist`);
171
+ else if (statSync(full).isDirectory()) for (const file of walk(full)) moved.add(posix.join(path, file));
172
+ else moved.add(path);
173
+ }
174
+ return moved;
67
175
  }
68
- if (map.log?.file) consumed.add(map.log.file);
69
176
 
70
- const stats = { taskLinks: 0, relinked: 0, deadLinks: [] };
71
- let ids = new Set();
177
+ function readTasks(docs, map, problems) {
178
+ const col = map.columns;
179
+ const rows = tables(read(docs, map.index))
180
+ .filter(table => [col.id, col.title, col.status].every(name => table.head.includes(name)))
181
+ .flatMap(table => table.rows);
182
+ if (!rows.length) throw new UsageError(`no table in ${map.index} has the columns "${col.id}", "${col.title}", "${col.status}"`);
72
183
 
73
- // from: the file the text was in. to: the file it will be in. Both relative to docs.
74
- function rewrite(text, from, to) {
75
- const fromDir = posix.dirname(from);
76
- const toDir = posix.dirname(to);
77
- const decode = s => { try { return decodeURI(s); } catch { return s; } };
78
- // Row anchors only existed as link targets for the old index; in a foreign file they are not ours to remove.
79
- return (from === to ? text : text.replace(/<a id="[^"]*"><\/a>/g, ''))
80
- .replace(/\[([^\[\]]*)\]\(([^)\s]+)\)( ✅)?/g, (all, label, href, tick) => {
184
+ const softMark = map.softPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
185
+ const depPattern = new RegExp(`(${softMark})?\\[?(${ID.source})`, 'g');
186
+ const tasks = [];
187
+ const ids = new Set();
188
+ for (const row of rows) {
189
+ const id = (plain(row[col.id]).match(ID) || [])[0];
190
+ if (!id) { problems.push(`row without an id: ${row[col.id].slice(0, 60)}`); continue; }
191
+ if (ids.has(id)) { problems.push(`duplicate id ${id}`); continue; }
192
+ ids.add(id);
193
+ const shown = statusText(row[col.status]);
194
+ const key = Object.keys(map.status).find(name => name.toLowerCase() === shown.toLowerCase());
195
+ if (!key) problems.push(`${id}: status "${shown}" is not in the mapping`);
196
+ const deps = [];
197
+ const softDeps = [];
198
+ for (const match of (col.deps ? row[col.deps] : '').matchAll(depPattern)) {
199
+ const list = match[1] ? softDeps : deps;
200
+ if (!list.includes(match[2])) list.push(match[2]);
201
+ }
202
+ const cell = name => (col[name] ? plain(row[col[name]] ?? '') : '');
203
+ const extra = {};
204
+ for (const [field, column] of Object.entries(map.fields)) {
205
+ const value = plain(row[column] ?? '');
206
+ if (value && !/^[—–-]$/.test(value)) extra[field] = value;
207
+ }
208
+ tasks.push({
209
+ id, row, deps, softDeps, extra,
210
+ title: cell('title'), status: key ? map.status[key] : 'todo',
211
+ milestone: cell('milestone'), priority: cell('priority'), started: cell('started'), finished: cell('finished'),
212
+ });
213
+ }
214
+ return { tasks, ids };
215
+ }
216
+
217
+ // Detail sections by task id, and the headings that have no row in the index.
218
+ function readDetails(docs, map, moved, ids) {
219
+ const details = new Map();
220
+ const stray = [];
221
+ const dir = map.details?.dir;
222
+ if (!dir) return { details, stray };
223
+ const mark = map.details.heading || '## ';
224
+ for (const file of [...moved].filter(path => path.startsWith(`${dir}/`) && isMarkdown(path))) {
225
+ let current = null;
226
+ for (const line of read(docs, file).split(/\r?\n/)) {
227
+ const id = line.startsWith(mark) ? (line.slice(mark.length).trim().match(new RegExp(`^${ID.source}$`)) || [])[0] : null;
228
+ if (id) {
229
+ current = { file, lines: [] };
230
+ if (ids.has(id)) details.set(id, current);
231
+ else stray.push(`${file}: ${id}`);
232
+ } else if (line.startsWith(mark)) {
233
+ current = null; // a same-level heading that is not a task ends the section
234
+ } else if (current) {
235
+ current.lines.push(line);
236
+ }
237
+ }
238
+ }
239
+ return { details, stray };
240
+ }
241
+
242
+ // The link from a folder to a target, keeping the trailing slash a link to a folder was written with.
243
+ const relativeHref = (fromDir, target, written) =>
244
+ encodeURI(posix.relative(fromDir, target)) + (written.endsWith('/') ? '/' : '');
245
+
246
+ // Rewrites the links of a text that lived in `from` and will live in `to` (paths relative to docs).
247
+ // A link to a task in an archived file becomes a wikilink; any other link into an archived file
248
+ // follows the file to the archive; a relative link is re-based when the text changes folder.
249
+ function makeRewriter({ moved, ids, archive, stats }) {
250
+ const decode = text => { try { return decodeURI(text); } catch { return text; } };
251
+ return function rewrite(text, from, to) {
252
+ const fromDir = posix.dirname(from);
253
+ const toDir = posix.dirname(to);
254
+ // Row anchors existed only as link targets for the old index. A file that stays where it is keeps its own.
255
+ const body = from === to ? text : text.replace(/<a id="[^"]*"><\/a>/g, '');
256
+ return body.replace(MARKDOWN_LINK, (all, label, href, tick) => {
81
257
  if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return all;
82
258
  const [path, anchor = ''] = href.split('#');
83
259
  const target = path ? posix.normalize(posix.join(fromDir, decode(path))) : from;
84
- if (consumed.has(target)) {
260
+ const suffix = `${anchor ? `#${anchor}` : ''})${tick ?? ''}`;
261
+ if (moved.has(target)) {
85
262
  const id = (anchor.match(new RegExp(`^${ID.source}$`, 'i')) || [])[0]?.toUpperCase();
86
- if (id && ids.has(id)) { stats.taskLinks++; return label.trim() === id ? `[[${id}]]` : `[[${id}|${label}]]`; }
87
- stats.deadLinks.push(`${to}: ${all.slice(0, 80)}`);
88
- return `[${label}](${encodeURI(posix.relative(toDir, posix.join(archive, target)))}${anchor ? `#${anchor}` : ''})${tick ?? ''}`;
263
+ if (id && ids.has(id)) {
264
+ stats.taskLinks++;
265
+ return label.trim() === id ? `[[${id}]]` : `[[${id}|${label}]]`;
266
+ }
267
+ stats.archiveLinks.push(`${to}: ${all.slice(0, 80)}`);
268
+ return `[${label}](${encodeURI(posix.relative(toDir, posix.join(archive, target)))}${suffix}`;
89
269
  }
90
270
  if (!path || fromDir === toDir) return all;
91
- stats.relinked++;
92
- return `[${label}](${encodeURI(posix.relative(toDir, target))}${anchor ? `#${anchor}` : ''})${tick ?? ''}`;
271
+ stats.rebased++;
272
+ return `[${label}](${relativeHref(toDir, target, path)}${suffix}`;
93
273
  });
274
+ };
94
275
  }
95
276
 
96
- // ---------- Read the index ----------
97
- const col = map.columns;
98
- const indexText = readFileSync(indexPath, 'utf8');
99
- const rows = tables(indexText).filter(t => [col.id, col.title, col.status].every(h => t.head.includes(h))).flatMap(t => t.rows);
100
- if (!rows.length) fail(`no table in ${map.index} has the columns "${col.id}", "${col.title}", "${col.status}"`);
101
-
102
- const plain = s => s.replace(/<[^>]+>/g, '').replace(/\[([^\[\]]*)\]\([^)]*\)/g, '$1').replace(/[`*]/g, '').trim();
103
- const statusOf = cell => {
104
- const text = plain(cell).replace(/✅/g, '').trim();
105
- const hit = Object.keys(map.status).find(k => k.toLowerCase() === text.toLowerCase());
106
- return hit ? map.status[hit] : null;
107
- };
108
-
109
- const tasks = [];
110
- const problems = [];
111
- for (const r of rows) {
112
- const id = (plain(r[col.id]).match(ID) || [])[0];
113
- if (!id) { problems.push(`row without an id: ${r[col.id].slice(0, 60)}`); continue; }
114
- if (ids.has(id)) { problems.push(`duplicate id ${id}`); continue; }
115
- ids.add(id);
116
- const status = statusOf(r[col.status]);
117
- if (!status) problems.push(`${id}: status "${plain(r[col.status])}" is not in the mapping`);
118
- const deps = [];
119
- const softDeps = [];
120
- for (const m of (col.deps ? r[col.deps] : '').matchAll(new RegExp(`(${soft.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?\\[?(${ID.source})`, 'g'))) {
121
- const list = m[1] ? softDeps : deps;
122
- if (!list.includes(m[2])) list.push(m[2]);
123
- }
124
- const get = k => (col[k] ? plain(r[col[k]] ?? '') : '');
125
- tasks.push({ id, row: r, title: get('title'), status: status || 'todo', deps, softDeps, milestone: get('milestone'), priority: get('priority'), started: get('started'), finished: get('finished') });
126
- }
127
-
128
- // ---------- Detail sections ----------
129
- const details = new Map();
130
- const strayDetails = [];
131
- if (detailDir) {
132
- const mark = map.details.heading || '## ';
133
- for (const file of [...consumed].filter(f => f.startsWith(`${detailDir}/`))) {
134
- let cur = null;
135
- for (const line of readFileSync(join(docs, file), 'utf8').split(/\r?\n/)) {
136
- const id = line.startsWith(mark) ? (line.slice(mark.length).trim().match(new RegExp(`^${ID.source}$`)) || [])[0] : null;
137
- if (id) { cur = { file, lines: [] }; (ids.has(id) ? details.set(id, cur) : strayDetails.push(`${file}: ${id}`)); continue; }
138
- if (line.startsWith(mark)) cur = null; // a same-level heading that is not a task ends the section
139
- if (cur) cur.lines.push(line);
140
- }
141
- }
277
+ // An archived file keeps its text, but it sits one folder deeper: its relative links to files
278
+ // that stayed behind are re-based so the archive remains readable. Links between archived files
279
+ // need no change, because the archive keeps their layout.
280
+ function rebaseArchived(text, path, moved, archive) {
281
+ const fromDir = posix.dirname(path);
282
+ const toDir = posix.dirname(posix.join(archive, path));
283
+ return text.replace(MARKDOWN_LINK, (all, label, href, tick) => {
284
+ const [target, anchor = ''] = href.split('#');
285
+ if (!target || /^[a-z][a-z0-9+.-]*:/i.test(href)) return all;
286
+ let decoded = target;
287
+ try { decoded = decodeURI(target); } catch { /* keep the raw path */ }
288
+ const resolved = posix.normalize(posix.join(fromDir, decoded));
289
+ if (moved.has(resolved)) return all;
290
+ return `[${label}](${relativeHref(toDir, resolved, target)}${anchor ? `#${anchor}` : ''})${tick ?? ''}`;
291
+ });
142
292
  }
143
293
 
144
- // ---------- Write ----------
145
- // Single-quoted YAML: the only escape is a doubled quote, so titles with " or \\ survive as written.
146
- const yaml = v => (/^[\w./-]*$/.test(v) ? v : `'${v.replace(/'/g, "''")}'`);
147
- const out = new Map();
148
- for (const t of tasks) {
149
- const to = `tasks/${t.id}.md`;
150
- const fm = ['---', 'type: task', `id: ${t.id}`, `title: ${yaml(t.title)}`, `status: ${t.status}`, `deps: [${t.deps.join(', ')}]`];
151
- if (t.softDeps.length) fm.push(`soft_deps: [${t.softDeps.join(', ')}]`);
152
- if (t.milestone) fm.push(`milestone: ${yaml(t.milestone)}`);
153
- if (/^\d+$/.test(t.priority)) fm.push(`priority: ${t.priority}`);
154
- fm.push(`started: ${t.started}`.trimEnd(), `finished: ${t.finished}`.trimEnd(), '---', '');
294
+ function taskFile(task, detail, map, rewrite) {
295
+ const path = `tasks/${task.id}.md`;
296
+ const frontmatter = ['type: task', `id: ${task.id}`, `title: ${yaml(task.title)}`, `status: ${task.status}`, `deps: [${task.deps.join(', ')}]`];
297
+ if (task.softDeps.length) frontmatter.push(`soft_deps: [${task.softDeps.join(', ')}]`);
298
+ if (task.milestone) frontmatter.push(`milestone: ${yaml(task.milestone)}`);
299
+ if (/^\d+$/.test(task.priority)) frontmatter.push(`priority: ${task.priority}`);
300
+ for (const [field, value] of Object.entries(task.extra)) frontmatter.push(`${field}: ${yaml(value)}`);
301
+ frontmatter.push(`started: ${task.started}`.trimEnd(), `finished: ${task.finished}`.trimEnd());
302
+
155
303
  const body = [];
156
- const d = details.get(t.id);
157
- if (d) body.push(rewrite(d.lines.join('\n').trim(), d.file, to), '');
158
- for (const [name, column] of Object.entries(map.sections || {})) {
159
- const cell = (t.row[column] || '').trim();
160
- if (cell) body.push(`## ${name}`, '', rewrite(cell, map.index, to), '');
161
- }
162
- out.set(to, fm.join('\n') + '\n' + body.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n');
163
- }
164
-
165
- let logEntries = 0;
166
- if (map.log?.file && existsSync(join(docs, map.log.file))) {
167
- const lc = map.log.columns;
168
- const entries = tables(readFileSync(join(docs, map.log.file), 'utf8'))
169
- .filter(t => [lc.date, lc.text].every(h => t.head.includes(h))).flatMap(t => t.rows)
170
- .filter(r => /^\d{4}-\d{2}-\d{2}/.test(plain(r[lc.date])));
171
- // Stable sort keeps same-day rows in reverse source order: sources list newest first, the log is oldest first.
172
- entries.reverse().sort((a, b) => plain(a[lc.date]).localeCompare(plain(b[lc.date])));
173
- logEntries = entries.length;
174
- const text = entries.map(r => `## [${plain(r[lc.date]).slice(0, 10)}] task | ${lc.id ? plain(r[lc.id]) || 'general' : 'general'}\n\n${rewrite(r[lc.text], map.log.file, 'log.md')}\n`).join('\n');
175
- out.set('log.md', `# Log\n\nAppend-only. Entry format: \`## [YYYY-MM-DD] kind | title\`.\n\n${text}`);
176
- }
177
-
178
- // Other markdown files that pointed at tasks in the consumed files.
179
- const others = [];
180
- (function walk(dir, rel) {
181
- for (const e of readdirSync(dir, { withFileTypes: true })) {
182
- if (e.name.startsWith('.') || e.name === 'node_modules') continue;
183
- const p = rel ? `${rel}/${e.name}` : e.name;
184
- if (e.isDirectory()) walk(join(dir, e.name), p);
185
- else if (e.name.endsWith('.md') && !consumed.has(p) && statSync(join(dir, e.name)).size < 2e6) others.push(p);
186
- }
187
- })(docs, '');
188
- let touchedOthers = 0;
189
- for (const p of others) {
190
- const before = readFileSync(join(docs, p), 'utf8');
191
- const after = rewrite(before, p, p);
192
- if (after === before) continue;
193
- touchedOthers++;
194
- out.set(p, after);
195
- }
196
-
197
- const counts = {};
198
- for (const t of tasks) counts[t.status] = (counts[t.status] || 0) + 1;
199
- const areas = [...new Set(tasks.map(t => t.id.slice(0, t.id.lastIndexOf('-'))))];
200
- const unknownDeps = tasks.flatMap(t => [...t.deps, ...t.softDeps].filter(d => !ids.has(d)).map(d => `${t.id} -> ${d}`));
201
-
202
- if (!dry) {
203
- for (const f of consumed) {
204
- if (!existsSync(join(docs, f))) continue;
205
- mkdirSync(dirname(join(docs, archive, f)), { recursive: true });
206
- renameSync(join(docs, f), join(docs, archive, f));
207
- }
208
- for (const [p, text] of out) {
209
- mkdirSync(dirname(join(docs, p)), { recursive: true });
210
- writeFileSync(join(docs, p), text);
211
- }
212
- }
213
-
214
- const show = (title, list, max = 10) => list.length && console.log(`\n${title} (${list.length}):\n${list.slice(0, max).map(s => ` ${s}`).join('\n')}${list.length > max ? `\n ... ${list.length - max} more` : ''}`);
215
- console.log(`${dry ? 'DRY RUN, nothing written\n' : ''}tasks: ${tasks.length} ${Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(' ')}`);
216
- console.log(`areas: ${areas.join(', ')}`);
217
- console.log(`with detail section: ${[...details.keys()].length} without: ${tasks.length - details.size}`);
218
- console.log(`task links turned into wikilinks: ${stats.taskLinks} relative links re-based: ${stats.relinked} other files updated: ${touchedOthers}`);
219
- if (map.log?.file) console.log(`log entries: ${logEntries}`);
220
- console.log(`archived to docs/${archive}/: ${[...consumed].join(', ')}`);
221
- show('problems', problems);
222
- show('dependencies on unknown ids', unknownDeps);
223
- show('detail sections without a table row (left in the archive)', strayDetails);
224
- show(`links into archived files that are not task links (now point into ${archive}/)`, stats.deadLinks);
225
- console.log(`\nnext: node <sw-init>/scripts/init.mjs --tasks --areas "${areas.map(a => `${a}=${a}`).join(',')}" then node docs/.sw/sw.mjs lint`);
304
+ if (detail) body.push(rewrite(detail.lines.join('\n').trim(), detail.file, path), '');
305
+ for (const [heading, column] of Object.entries(map.sections)) {
306
+ const cell = (task.row[column] || '').trim();
307
+ if (cell) body.push(`## ${heading}`, '', rewrite(cell, map.index, path), '');
308
+ }
309
+ const text = body.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
310
+ return [path, `---\n${frontmatter.join('\n')}\n---\n\n${text}\n`];
311
+ }
312
+
313
+ function logFile(docs, map, rewrite) {
314
+ if (!map.log?.file || !existsSync(join(docs, map.log.file))) return null;
315
+ const col = map.log.columns;
316
+ const rows = tables(read(docs, map.log.file))
317
+ .filter(table => [col.date, col.text].every(name => table.head.includes(name)))
318
+ .flatMap(table => table.rows)
319
+ .filter(row => /^\d{4}-\d{2}-\d{2}/.test(plain(row[col.date])));
320
+ // Sources list newest first and the log is oldest first; a stable sort after reversing keeps
321
+ // same-day rows in the order they happened.
322
+ rows.reverse().sort((a, b) => plain(a[col.date]).localeCompare(plain(b[col.date])));
323
+ const entries = rows.map(row => {
324
+ const title = (col.id && plain(row[col.id])) || 'general';
325
+ return `## [${plain(row[col.date]).slice(0, 10)}] task | ${title}\n\n${rewrite(row[col.text], map.log.file, 'log.md')}\n`;
326
+ });
327
+ return { count: entries.length, text: `# Log\n\nAppend-only. Entry format: \`## [YYYY-MM-DD] kind | title\`.\n\n${entries.join('\n')}` };
328
+ }
329
+
330
+ // Works out everything the conversion will write and move, without touching the disk.
331
+ function plan(docs, map) {
332
+ if (!existsSync(join(docs, map.index))) throw new UsageError(`no ${map.index} in ${docs}`);
333
+ if (existsSync(join(docs, '.sw', 'config.json'))) throw new UsageError('docs/.sw/config.json exists: this folder is already a Superwiki vault');
334
+ const archiveDir = join(docs, map.archive);
335
+ // Empty folders left by an undone attempt are not an archive.
336
+ if (existsSync(archiveDir) && walk(archiveDir).length) throw new UsageError(`docs/${map.archive} has files in it: choose another "archive" name or remove it`);
337
+
338
+ const problems = [];
339
+ const moved = filesToArchive(docs, map, problems);
340
+ const { tasks, ids } = readTasks(docs, map, problems);
341
+ const { details, stray } = readDetails(docs, map, moved, ids);
342
+ const stats = { taskLinks: 0, rebased: 0, archiveLinks: [] };
343
+ const rewrite = makeRewriter({ moved, ids, archive: map.archive, stats });
344
+
345
+ const writes = new Map();
346
+ for (const task of tasks) writes.set(...taskFile(task, details.get(task.id), map, rewrite));
347
+ const log = logFile(docs, map, rewrite);
348
+ if (log) writes.set('log.md', log.text);
349
+
350
+ // Files that stay where they are but pointed at something that moves.
351
+ const updated = [];
352
+ for (const path of walk(docs).filter(file => isMarkdown(file) && !moved.has(file))) {
353
+ if (statSync(join(docs, path)).size > 2e6) continue;
354
+ const before = read(docs, path);
355
+ const after = rewrite(before, path, path);
356
+ if (after !== before) { writes.set(path, after); updated.push(path); }
357
+ }
358
+
359
+ for (const path of [...moved].filter(file => isMarkdown(file) && existsSync(join(docs, file)))) {
360
+ const before = read(docs, path);
361
+ const after = rebaseArchived(before, path, moved, map.archive);
362
+ if (after !== before) writes.set(posix.join(map.archive, path), after);
363
+ }
364
+
365
+ // Superwiki owns wiki/, tasks/ and plans/. A file already in one of them would be read as a
366
+ // wiki page, task or plan after the conversion, and lint would reject it.
367
+ const foreign = VAULT_FOLDERS.flatMap(folder => (existsSync(join(docs, folder)) ? walk(join(docs, folder)).map(file => `${folder}/${file}`) : []))
368
+ .filter(path => isMarkdown(path) && !moved.has(path));
369
+ for (const path of foreign) problems.push(`docs/${path} is in a folder Superwiki uses: add it (or its folder) to "archiveAlso", or move it elsewhere first`);
370
+
371
+ for (const task of tasks) {
372
+ for (const dep of [...task.deps, ...task.softDeps]) if (!ids.has(dep)) problems.push(`${task.id} depends on ${dep}, which is not in the index`);
373
+ }
374
+
375
+ const counts = {};
376
+ for (const task of tasks) counts[task.status] = (counts[task.status] || 0) + 1;
377
+ const areas = [...new Set(tasks.map(task => task.id.slice(0, task.id.lastIndexOf('-'))))];
378
+ return { map, moved, writes, problems, stray, stats, updated, counts, areas, taskCount: tasks.length, detailCount: details.size, logCount: log?.count ?? null };
379
+ }
380
+
381
+ // A folder that held only archived files would be left behind empty. Remove those, deepest first;
382
+ // rmdir refuses a folder that still has something in it, which is the check.
383
+ function removeEmptiedFolders(docs, moved) {
384
+ const folders = new Set();
385
+ for (const path of moved) {
386
+ for (let dir = posix.dirname(path); dir !== '.'; dir = posix.dirname(dir)) folders.add(dir);
387
+ }
388
+ for (const dir of [...folders].sort((a, b) => b.length - a.length)) {
389
+ try { rmdirSync(join(docs, dir)); } catch { /* not empty, or already gone */ }
390
+ }
391
+ }
392
+
393
+ function apply(docs, result) {
394
+ const { map, moved, writes } = result;
395
+ for (const path of moved) {
396
+ if (!existsSync(join(docs, path))) continue;
397
+ const target = join(docs, map.archive, path);
398
+ mkdirSync(dirname(target), { recursive: true });
399
+ renameSync(join(docs, path), target);
400
+ }
401
+ removeEmptiedFolders(docs, moved);
402
+ for (const [path, text] of writes) {
403
+ mkdirSync(dirname(join(docs, path)), { recursive: true });
404
+ writeFileSync(join(docs, path), text);
405
+ }
406
+ }
407
+
408
+ function report(result, dryRun) {
409
+ const { map, counts, areas, stats } = result;
410
+ const list = (title, items) => {
411
+ if (!items.length) return;
412
+ const more = items.length > LIST_LIMIT ? `\n ... ${items.length - LIST_LIMIT} more` : '';
413
+ console.log(`\n${title} (${items.length}):\n${items.slice(0, LIST_LIMIT).map(item => ` ${item}`).join('\n')}${more}`);
414
+ };
415
+ if (dryRun) console.log('DRY RUN, nothing written');
416
+ console.log(`tasks: ${result.taskCount} ${Object.entries(counts).map(([status, n]) => `${status} ${n}`).join(' ')}`);
417
+ console.log(`areas: ${areas.join(', ')}`);
418
+ console.log(`with detail section: ${result.detailCount} without: ${result.taskCount - result.detailCount}`);
419
+ console.log(`task links turned into wikilinks: ${stats.taskLinks} relative links re-based: ${stats.rebased} other files updated: ${result.updated.length}`);
420
+ if (result.logCount !== null) console.log(`log entries: ${result.logCount} -> docs/log.md`);
421
+ console.log(`archived to docs/${map.archive}/: ${result.moved.size} files`);
422
+
423
+ list('PROBLEMS, fix these in the mapping or the files and run again', result.problems);
424
+ if (!result.problems.length) console.log('\nno problems');
425
+ console.log('\nFor information; none of these blocks the conversion:');
426
+ list('other files whose task links were rewritten', result.updated);
427
+ list('detail sections without a table row (they stay in the archive)', result.stray);
428
+ list(`links into archived files that are not task links (now point into ${map.archive}/)`, stats.archiveLinks);
429
+ console.log(`\nnext: node <init-dir>/scripts/init.mjs --tasks --areas "${areas.map(area => `${area}=${area}`).join(',')}" then node docs/.sw/sw.mjs status and lint`);
430
+ console.log('`status` splits "todo" into ready and blocked; their sum is the todo count above.');
431
+ }
432
+
433
+ // ---------- Entry point ----------
434
+
435
+ function parseArgs(argv) {
436
+ const options = { docs: 'docs', mapping: null, inspect: false, dryRun: false, help: false };
437
+ for (let i = 0; i < argv.length; i++) {
438
+ if (argv[i] === '--docs') options.docs = argv[++i];
439
+ else if (argv[i] === '--mapping') options.mapping = argv[++i];
440
+ else if (argv[i] === '--inspect') options.inspect = true;
441
+ else if (argv[i] === '--dry-run') options.dryRun = true;
442
+ else if (argv[i] === '--help') options.help = true;
443
+ else throw new UsageError(`unknown argument: ${argv[i]}`);
444
+ }
445
+ return options;
446
+ }
447
+
448
+ function main(argv) {
449
+ try {
450
+ const options = parseArgs(argv);
451
+ if (options.help) { console.log(HELP); return 0; }
452
+ const docs = resolve(options.docs || 'docs');
453
+ if (!existsSync(docs)) throw new UsageError(`no docs folder at ${docs}`);
454
+ if (options.inspect) { inspect(docs); return 0; }
455
+ if (!options.mapping) throw new UsageError(HELP);
456
+ const result = plan(docs, readMapping(resolve(options.mapping)));
457
+ if (!options.dryRun) apply(docs, result);
458
+ report(result, options.dryRun);
459
+ return 0;
460
+ } catch (error) {
461
+ if (!(error instanceof UsageError)) throw error;
462
+ console.error(error.message);
463
+ return 2;
464
+ }
465
+ }
466
+
467
+ process.exitCode = main(process.argv.slice(2));