superwiki 0.1.1 → 0.1.3
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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +7 -7
- package/package.json +1 -1
- package/skills/sw-config/SKILL.md +19 -8
- package/skills/sw-config/assets/implementer.md +34 -10
- package/skills/sw-config/assets/planner.md +48 -28
- package/skills/sw-config/assets/reviewer.md +41 -0
- package/skills/sw-config/scripts/config.mjs +6 -1
- package/skills/sw-implement/SKILL.md +40 -23
- package/skills/sw-init/assets/agents-block.md +1 -1
- package/skills/sw-init/assets/sw.mjs +5 -1
- package/skills/sw-init/assets/templates/guide.md +5 -4
- package/skills/sw-init/assets/templates/task.md +5 -1
- package/skills/sw-init/assets/viewer.html +2 -0
- package/skills/sw-init/scripts/init.mjs +178 -99
- package/skills/sw-migrate/SKILL.md +36 -25
- package/skills/sw-migrate/scripts/migrate.mjs +422 -180
- package/skills/sw-plan/SKILL.md +5 -5
|
@@ -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,
|
|
5
|
-
|
|
6
|
-
import {
|
|
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 --
|
|
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
|
-
"
|
|
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
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
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
|
|
48
|
+
const found = [];
|
|
47
49
|
for (let i = 0; i + 1 < lines.length; i++) {
|
|
48
|
-
|
|
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((
|
|
57
|
+
rows.push(Object.fromEntries(head.map((name, k) => [name, cells[k] ?? ''])));
|
|
55
58
|
}
|
|
56
|
-
|
|
59
|
+
found.push({ line: i + 1, head, rows });
|
|
57
60
|
i = j - 1;
|
|
58
61
|
}
|
|
59
|
-
return
|
|
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
|
-
// ----------
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
.
|
|
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
|
-
|
|
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)) {
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
92
|
-
return `[${label}](${
|
|
271
|
+
stats.rebased++;
|
|
272
|
+
return `[${label}](${relativeHref(toDir, target, path)}${suffix}`;
|
|
93
273
|
});
|
|
274
|
+
};
|
|
94
275
|
}
|
|
95
276
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (map.log?.file
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
.filter(
|
|
170
|
-
.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
for (const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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));
|