cans-spec 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +339 -0
- package/package.json +38 -0
- package/src/cli.ts +48 -0
- package/src/commands/budget.ts +238 -0
- package/src/commands/check.ts +422 -0
- package/src/commands/done.ts +171 -0
- package/src/commands/export.ts +223 -0
- package/src/commands/import.ts +436 -0
- package/src/commands/init.ts +184 -0
- package/src/commands/new.ts +138 -0
- package/src/commands/status.ts +152 -0
- package/src/converters/index.ts +4 -0
- package/src/converters/logseq.ts +42 -0
- package/src/converters/obsidian.ts +95 -0
- package/src/converters/opml.ts +143 -0
- package/src/converters/shared.ts +268 -0
- package/src/core/args.ts +79 -0
- package/src/core/fs.ts +309 -0
- package/src/core/index.ts +10 -0
- package/src/core/outline.ts +237 -0
- package/src/core/output.ts +300 -0
- package/src/core/overflow.ts +75 -0
- package/src/core/redundancy.ts +261 -0
- package/src/core/refs.ts +275 -0
- package/src/core/rules.ts +483 -0
- package/src/core/structure.ts +86 -0
- package/src/core/style.ts +75 -0
- package/src/core/token-budget.ts +284 -0
- package/src/types.ts +284 -0
- package/templates/AGENTS.md +209 -0
- package/templates/_rules.yaml +46 -0
- package/templates/adr-template.md +28 -0
- package/templates/task-template.md +15 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { join, basename, relative } from 'path';
|
|
2
|
+
import type { ExportResult, ExportFormat, OutlineNode, ExternalNode } from '../types';
|
|
3
|
+
import {
|
|
4
|
+
resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, discoverAdrs,
|
|
5
|
+
mkdirp, dirExists, exists,
|
|
6
|
+
} from '../core/fs';
|
|
7
|
+
import { parseOutline } from '../core/outline';
|
|
8
|
+
import { serializeOpml } from '../converters/opml';
|
|
9
|
+
import { serializeLogseq } from '../converters/logseq';
|
|
10
|
+
import { serializeObsidian } from '../converters/obsidian';
|
|
11
|
+
|
|
12
|
+
export interface ExportArgs {
|
|
13
|
+
format: ExportFormat;
|
|
14
|
+
from: string | null;
|
|
15
|
+
vault: string | null;
|
|
16
|
+
includeTasks: boolean;
|
|
17
|
+
dryRun: boolean;
|
|
18
|
+
json: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FORMATS: readonly string[] = ['opml', 'dynalist', 'logseq', 'obsidian', 'all'];
|
|
22
|
+
|
|
23
|
+
export function parseExportArgs(args: string[]): ExportArgs {
|
|
24
|
+
let format = '';
|
|
25
|
+
let from: string | null = null;
|
|
26
|
+
let vault: string | null = null;
|
|
27
|
+
let includeTasks = false;
|
|
28
|
+
let dryRun = false;
|
|
29
|
+
let json = false;
|
|
30
|
+
const positional: string[] = [];
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const a = args[i];
|
|
33
|
+
if (a === '--from') {
|
|
34
|
+
from = args[i + 1] ?? null;
|
|
35
|
+
} else if (a === '--vault') {
|
|
36
|
+
vault = args[i + 1] ?? null;
|
|
37
|
+
} else if (a === '--include-tasks') {
|
|
38
|
+
includeTasks = true;
|
|
39
|
+
} else if (a === '--dry-run') {
|
|
40
|
+
dryRun = true;
|
|
41
|
+
} else if (a === '--json') {
|
|
42
|
+
json = true;
|
|
43
|
+
} else if (!a.startsWith('--')) {
|
|
44
|
+
positional.push(a);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
format = positional[0] ?? '';
|
|
48
|
+
return { format: format as ExportFormat, from, vault, includeTasks, dryRun, json };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** see: X#Y → '→ X#Y' (opml/dynalist) | '[[X/Y]]' (logseq) | '[[X#Y]]' (obsidian). */
|
|
52
|
+
function transformRefs(text: string, fmt: string): string {
|
|
53
|
+
if (fmt === 'opml' || fmt === 'dynalist') {
|
|
54
|
+
return text.replace(
|
|
55
|
+
/\bsee:?\s+([^\s#]+)(?:#([^\s#]+))?/g,
|
|
56
|
+
(_m, file: string, anchor?: string) => `→ ${file}${anchor !== undefined ? `#${anchor}` : ''}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (fmt === 'logseq') {
|
|
60
|
+
return text.replace(
|
|
61
|
+
/\bsee:?\s+([^\s#]+)(?:#([^\s#]+))?/g,
|
|
62
|
+
(_m, file: string, anchor?: string) => {
|
|
63
|
+
const base = file.replace(/\.md$/, '');
|
|
64
|
+
return anchor !== undefined ? `[[${base}/${anchor}]]` : `[[${base}]]`;
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (fmt === 'obsidian') {
|
|
69
|
+
return text.replace(
|
|
70
|
+
/\bsee:?\s+([^\s#]+)(?:#([^\s#]+))?/g,
|
|
71
|
+
(_m, file: string, anchor?: string) => {
|
|
72
|
+
const base = file.replace(/\.md$/, '');
|
|
73
|
+
return anchor !== undefined ? `[[${base}#${anchor}]]` : `[[${base}]]`;
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** ← @human → '⏳ Human'; ← agent-1 → '[agent-1]' / 'agent-1:: assigned' / '🤖 agent-1'. */
|
|
81
|
+
function transformOwner(text: string, fmt: string): string {
|
|
82
|
+
let s = text.replace(/←\s*@human\b/g, '⏳ Human');
|
|
83
|
+
if (fmt === 'opml' || fmt === 'dynalist') {
|
|
84
|
+
s = s.replace(/←\s*(\S+)/g, (_m, owner: string) => `[${owner}]`);
|
|
85
|
+
} else if (fmt === 'logseq') {
|
|
86
|
+
s = s.replace(/←\s*(\S+)/g, (_m, owner: string) => `${owner}:: assigned`);
|
|
87
|
+
} else if (fmt === 'obsidian') {
|
|
88
|
+
s = s.replace(/←\s*(\S+)/g, (_m, owner: string) => `🤖 ${owner}`);
|
|
89
|
+
}
|
|
90
|
+
return s;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function toExternal(node: OutlineNode, fmt: string): ExternalNode {
|
|
94
|
+
return {
|
|
95
|
+
text: transformOwner(transformRefs(node.text, fmt), fmt),
|
|
96
|
+
indent: node.indent,
|
|
97
|
+
isTask: node.isTask,
|
|
98
|
+
isDone: node.isDone,
|
|
99
|
+
children: node.children.map(c => toExternal(c, fmt)),
|
|
100
|
+
metadata: {},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function serializeFor(nodes: ExternalNode[], fmt: string, title: string): string {
|
|
105
|
+
if (fmt === 'opml' || fmt === 'dynalist') return serializeOpml(nodes, title);
|
|
106
|
+
if (fmt === 'logseq') return serializeLogseq(nodes);
|
|
107
|
+
if (fmt === 'obsidian') return serializeObsidian(nodes);
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function outputFileName(specRel: string, fmt: string): string {
|
|
112
|
+
const base = basename(specRel);
|
|
113
|
+
if (fmt === 'opml' || fmt === 'dynalist') return `${base.slice(0, -3)}.opml`;
|
|
114
|
+
return base;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function run(args: string[]): Promise<ExportResult> {
|
|
118
|
+
const opts = parseExportArgs(args);
|
|
119
|
+
const fmt: string = opts.format.toLowerCase(); // §28 formats are lowercase; accept OPML/All casing
|
|
120
|
+
|
|
121
|
+
if (!FORMATS.includes(fmt)) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false, command: 'export', exitCode: 1,
|
|
124
|
+
format: fmt, outputDir: '', filesExported: 0,
|
|
125
|
+
error: fmt === ''
|
|
126
|
+
? 'usage: cans export <format>\n Formats: opml, dynalist, logseq, obsidian, all'
|
|
127
|
+
: `unknown format "${opts.format}" — valid: opml, dynalist, logseq, obsidian, all`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// §37/§19 (QA-05 F15 / QA-10 #9): a --from that does not name an existing
|
|
132
|
+
// directory is user-correctable — fail with a stated reason, never
|
|
133
|
+
// success-shaped nothing (ok:true, filesExported: 0).
|
|
134
|
+
if (opts.from !== null && !dirExists(opts.from)) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false, command: 'export', exitCode: 1,
|
|
137
|
+
format: fmt, outputDir: '', filesExported: 0,
|
|
138
|
+
error: `--from directory not found: ${opts.from}\n Check the path (it must be an existing directory) and try again.`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const workspace = opts.from ?? resolveWorkspaceRoot();
|
|
143
|
+
if (workspace === null) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false, command: 'export', exitCode: 1,
|
|
146
|
+
format: fmt, outputDir: '', filesExported: 0,
|
|
147
|
+
error: 'no cans workspace found — run `cans init` first',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const sources = discoverSpecFiles(workspace);
|
|
152
|
+
// §28: exports exclude ONLY `_collab/`, `_adr/_archive/`, `_rules.yaml`,
|
|
153
|
+
// `AGENTS.md` — active `_adr/` records are spec surface and must be exported
|
|
154
|
+
// (QA-05 F18). discoverAdrs is [] without _adr/, skips `_template.md`, and its
|
|
155
|
+
// flat `_adr/*.md` glob never matches `_archive/` subdir files.
|
|
156
|
+
sources.push(...discoverAdrs(workspace));
|
|
157
|
+
if (opts.includeTasks && dirExists(join(workspace, '_tasks'))) {
|
|
158
|
+
sources.push(...discoverActiveTasks(workspace));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const formats = fmt === 'all'
|
|
162
|
+
? ['opml', 'dynalist', 'logseq', 'obsidian']
|
|
163
|
+
: [fmt];
|
|
164
|
+
|
|
165
|
+
const baseDir = opts.vault ?? join(process.cwd(), 'cans-export');
|
|
166
|
+
const outputDir = fmt === 'all' ? baseDir : join(baseDir, fmt);
|
|
167
|
+
|
|
168
|
+
// §37/§19 (QA-08 E14/E15): an output path occupied by a FILE is
|
|
169
|
+
// user-correctable — ✗ exit 1 with a fix hint, never a raw ENOTDIR
|
|
170
|
+
// internal error. Pre-check with stat; the write loop below also maps
|
|
171
|
+
// ENOTDIR/EEXIST from mkdir/write as a belt-and-braces net (EACCES and
|
|
172
|
+
// other unexpected errnos stay internal per QA-10 D2).
|
|
173
|
+
const notDirError = (p: string): ExportResult => ({
|
|
174
|
+
ok: false, command: 'export', exitCode: 1,
|
|
175
|
+
format: fmt, outputDir: '', filesExported: 0,
|
|
176
|
+
error: `${p} exists and is not a directory — remove/rename it or choose another output path`,
|
|
177
|
+
});
|
|
178
|
+
if (exists(baseDir) && !dirExists(baseDir)) return notDirError(baseDir);
|
|
179
|
+
if (fmt !== 'all' && exists(outputDir) && !dirExists(outputDir)) return notDirError(outputDir);
|
|
180
|
+
|
|
181
|
+
let filesExported = 0;
|
|
182
|
+
for (const f of formats) {
|
|
183
|
+
const fmtDir = fmt === 'all' ? join(baseDir, f) : outputDir;
|
|
184
|
+
if (exists(fmtDir) && !dirExists(fmtDir)) return notDirError(fmtDir);
|
|
185
|
+
for (const rel of sources) {
|
|
186
|
+
let text = '';
|
|
187
|
+
try {
|
|
188
|
+
text = await Bun.file(join(workspace, rel)).text();
|
|
189
|
+
} catch {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
let tree: OutlineNode[] = [];
|
|
193
|
+
try {
|
|
194
|
+
tree = parseOutline(text, rel);
|
|
195
|
+
} catch {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const external = tree.map(n => toExternal(n, f));
|
|
199
|
+
if (external.length === 0) continue;
|
|
200
|
+
const content = serializeFor(external, f, basename(rel));
|
|
201
|
+
if (content === '') continue;
|
|
202
|
+
if (!opts.dryRun) {
|
|
203
|
+
try {
|
|
204
|
+
mkdirp(fmtDir);
|
|
205
|
+
await Bun.write(join(fmtDir, outputFileName(rel, f)), content);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
const code = (e as NodeJS.ErrnoException | null)?.code;
|
|
208
|
+
if (code === 'ENOTDIR' || code === 'EEXIST') return notDirError(fmtDir);
|
|
209
|
+
throw e; // EACCES etc. remain internal (§37: unexpected failures only)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
filesExported++;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
ok: true, command: 'export', exitCode: 0,
|
|
218
|
+
format: fmt,
|
|
219
|
+
outputDir: relative(process.cwd(), outputDir) || '.', // §35 fixture: relative to cwd
|
|
220
|
+
filesExported,
|
|
221
|
+
dryRun: opts.dryRun || undefined,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { join, basename, dirname } from 'path';
|
|
2
|
+
import { readdirSync } from 'fs';
|
|
3
|
+
import type {
|
|
4
|
+
ImportResult, ImportFormat, ImportConflict, MergeStrategy, ExternalNode,
|
|
5
|
+
} from '../types';
|
|
6
|
+
import { resolveWorkspaceRoot, discoverSpecFiles, mkdirp, isFile, dirExists } from '../core/fs';
|
|
7
|
+
import { convertArrowRefs, parseOpml, parseOpmlTitle } from '../converters/opml';
|
|
8
|
+
import { parseLogseq } from '../converters/logseq';
|
|
9
|
+
import { parseObsidian, stripFrontmatter } from '../converters/obsidian';
|
|
10
|
+
import {
|
|
11
|
+
serializeToCans, parseFromCans, stripMetadata, parseCheckbox,
|
|
12
|
+
extractOverflowContent, type OverflowExtraction,
|
|
13
|
+
} from '../converters/shared';
|
|
14
|
+
|
|
15
|
+
export interface ImportArgs {
|
|
16
|
+
format: ImportFormat;
|
|
17
|
+
path: string;
|
|
18
|
+
out: string | null;
|
|
19
|
+
dryRun: boolean;
|
|
20
|
+
mergeStrategy: MergeStrategy;
|
|
21
|
+
/** Raw `--merge-strategy` value as given, so invalid enums can be rejected (QA-05 F10). */
|
|
22
|
+
mergeStrategyRaw: string | null;
|
|
23
|
+
json: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const FORMATS: readonly string[] = ['opml', 'dynalist', 'logseq', 'obsidian'];
|
|
27
|
+
const STRATEGIES: readonly MergeStrategy[] = ['cans-wins', 'import-wins', 'ask'];
|
|
28
|
+
|
|
29
|
+
export function parseImportArgs(args: string[]): ImportArgs {
|
|
30
|
+
const positional: string[] = [];
|
|
31
|
+
let out: string | null = null;
|
|
32
|
+
let dryRun = false;
|
|
33
|
+
let mergeStrategy: MergeStrategy = 'cans-wins';
|
|
34
|
+
let mergeStrategyRaw: string | null = null;
|
|
35
|
+
let json = false;
|
|
36
|
+
for (let i = 0; i < args.length; i++) {
|
|
37
|
+
const a = args[i];
|
|
38
|
+
if (a === '--out') {
|
|
39
|
+
out = args[i + 1] ?? null;
|
|
40
|
+
} else if (a === '--dry-run') {
|
|
41
|
+
dryRun = true;
|
|
42
|
+
} else if (a === '--merge-strategy') {
|
|
43
|
+
const s = args[i + 1] ?? null;
|
|
44
|
+
mergeStrategyRaw = s;
|
|
45
|
+
if (s !== null && (STRATEGIES as readonly string[]).includes(s)) mergeStrategy = s as MergeStrategy;
|
|
46
|
+
} else if (a === '--json') {
|
|
47
|
+
json = true;
|
|
48
|
+
} else if (!a.startsWith('--')) {
|
|
49
|
+
positional.push(a);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
format: (positional[0] ?? '') as ImportFormat,
|
|
54
|
+
path: positional[1] ?? '',
|
|
55
|
+
out,
|
|
56
|
+
dryRun,
|
|
57
|
+
mergeStrategy,
|
|
58
|
+
mergeStrategyRaw,
|
|
59
|
+
json,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fail(format: string, source: string, error: string): ImportResult {
|
|
64
|
+
return {
|
|
65
|
+
ok: false, command: 'import', exitCode: 1,
|
|
66
|
+
format, source, newFiles: [], merged: [], conflicts: [], error,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function slugify(input: string): string {
|
|
71
|
+
return input
|
|
72
|
+
.trim()
|
|
73
|
+
.toLowerCase()
|
|
74
|
+
.replace(/["“”]/g, '')
|
|
75
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
76
|
+
.replace(/^-+|-+$/g, '');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** §4 canonical ref form: spec-slug ref targets carry the `.md` extension when
|
|
80
|
+
* written into the workspace (`see: 02-authentication#Sessions` →
|
|
81
|
+
* `see: 02-authentication.md#Sessions`). Converter output stays mechanical;
|
|
82
|
+
* the importer emits workspace-conformant refs (QA-05 F2/F3). Idempotent. */
|
|
83
|
+
function canonicalizeRefTargets(text: string): string {
|
|
84
|
+
return text.replace(
|
|
85
|
+
/\bsee:?\s+([^\s#]+)(#[^\s]+)?/g,
|
|
86
|
+
(m, target: string, anchor: string | undefined) => {
|
|
87
|
+
if (/^\d{2}-/.test(target) && !target.endsWith('.md')) {
|
|
88
|
+
return `see: ${target}.md${anchor ?? ''}`;
|
|
89
|
+
}
|
|
90
|
+
return m;
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Normalized key for fuzzy text matching: lowercase, strip punctuation, collapse whitespace. */
|
|
96
|
+
function normKey(text: string): string {
|
|
97
|
+
return text.toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Near-match: same words (len > 1) with ≥ 0.75 overlap.
|
|
102
|
+
* Threshold note: the pasted patch said 0.8, but the QA-05 F8 flagship conflict
|
|
103
|
+
* pair — "Expire after 24 hours" vs "Expire after 48 hours" — shares 3 of 4
|
|
104
|
+
* words (0.75) and MUST be flagged, so the threshold is tuned to 0.75.
|
|
105
|
+
*/
|
|
106
|
+
function isNearMatch(a: string, b: string): boolean {
|
|
107
|
+
const wa = new Set(normKey(a).split(' ').filter(w => w.length > 1));
|
|
108
|
+
const wb = new Set(normKey(b).split(' ').filter(w => w.length > 1));
|
|
109
|
+
if (wa.size === 0 || wb.size === 0) return false;
|
|
110
|
+
let shared = 0;
|
|
111
|
+
for (const w of wa) if (wb.has(w)) shared++;
|
|
112
|
+
return shared / Math.max(wa.size, wb.size) >= 0.75;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Source files to import: a single file, or every supported file inside a directory. */
|
|
116
|
+
function sourceFiles(path: string, format: string): string[] | null {
|
|
117
|
+
if (isFile(path)) return [path];
|
|
118
|
+
if (dirExists(path)) {
|
|
119
|
+
const ext = format === 'opml' || format === 'dynalist' ? '.opml' : '.md';
|
|
120
|
+
let names: string[] = [];
|
|
121
|
+
try {
|
|
122
|
+
names = readdirSync(path).filter(n => n.endsWith(ext));
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return names.sort().map(n => join(path, n));
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Map every node's text (depth-first) through `f`, preserving structure. */
|
|
132
|
+
function mapText(nodes: ExternalNode[], f: (t: string) => string): ExternalNode[] {
|
|
133
|
+
return nodes.map((n) => ({ ...n, text: f(n.text), children: mapText(n.children, f) }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseSource(text: string, format: string): ExternalNode[] {
|
|
137
|
+
if (format === 'opml' || format === 'dynalist') {
|
|
138
|
+
let nodes = parseOpml(text);
|
|
139
|
+
// §28 table inverse: the exported `→ X.md#Y` marker restores as `see: X.md#Y`
|
|
140
|
+
// (QA-05 F16 / QA-09 D9 — refs must survive the OPML round-trip).
|
|
141
|
+
nodes = mapText(nodes, (t) => convertArrowRefs(t));
|
|
142
|
+
if (format === 'dynalist') {
|
|
143
|
+
// dynalist exports carry app metadata (^block-ids, #tags, emphasis) inside text
|
|
144
|
+
nodes = mapText(nodes, (t) => stripMetadata(t, 'dynalist'));
|
|
145
|
+
}
|
|
146
|
+
return nodes;
|
|
147
|
+
}
|
|
148
|
+
if (format === 'logseq') return parseLogseq(text);
|
|
149
|
+
if (format === 'obsidian') return parseObsidian(stripFrontmatter(text));
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Max spec number in the target dir + 1 (starts at 7). */
|
|
154
|
+
function nextSpecNumber(targetDir: string): number {
|
|
155
|
+
let max = 6;
|
|
156
|
+
for (const rel of discoverSpecFiles(targetDir)) {
|
|
157
|
+
const m = basename(rel).match(/^(\d{2})-/);
|
|
158
|
+
if (m !== null) max = Math.max(max, Number(m[1]));
|
|
159
|
+
}
|
|
160
|
+
return max + 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Existing spec file with the same slug (ignoring the NN- prefix) — merge target. */
|
|
164
|
+
function findExistingBySlug(targetDir: string, slug: string): string | null {
|
|
165
|
+
for (const rel of discoverSpecFiles(targetDir)) {
|
|
166
|
+
const stripped = basename(rel).replace(/\.md$/, '').replace(/^\d{2}-/, '');
|
|
167
|
+
if (slugify(stripped) === slug) return rel;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface MergeOutcome {
|
|
173
|
+
content: string | null; // null = no write (ask)
|
|
174
|
+
conflicts: ImportConflict[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Tree-level merge (QA-05 F8/F9). One single-pass walk of the import tree:
|
|
179
|
+
* for each imported node, match it against the existing tree by normalized text
|
|
180
|
+
* (global exact index) and, failing that, against its sibling slot by word
|
|
181
|
+
* overlap; brand-new nodes are inserted under the CORRECT parent so tree
|
|
182
|
+
* position is preserved (the old flat-append corrupts the hierarchy).
|
|
183
|
+
* exact normalized match → conflict only if text differs
|
|
184
|
+
* (cans-wins keeps the CANS text, import-wins overwrites it)
|
|
185
|
+
* near-match (word overlap ≥ 0.75) → conflict + strategy
|
|
186
|
+
* new node → inserted under the matched/near parent; `ask` reports it, no write
|
|
187
|
+
*/
|
|
188
|
+
function mergeInto(
|
|
189
|
+
existingText: string,
|
|
190
|
+
imported: ExternalNode[],
|
|
191
|
+
strategy: MergeStrategy,
|
|
192
|
+
relName: string,
|
|
193
|
+
): MergeOutcome {
|
|
194
|
+
const conflicts: ImportConflict[] = [];
|
|
195
|
+
const existingTree = parseFromCans(existingText);
|
|
196
|
+
|
|
197
|
+
// Real source line per existing node text (first occurrence, document order).
|
|
198
|
+
const lineOfKey = new Map<string, number>();
|
|
199
|
+
existingText.split(/\r?\n/).forEach((l, i) => {
|
|
200
|
+
if (!/^\s*-\s+/.test(l)) return;
|
|
201
|
+
const k = normKey(parseCheckbox(l).clean);
|
|
202
|
+
if (k !== '' && !lineOfKey.has(k)) lineOfKey.set(k, i + 1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Index existing nodes by normalized text (first occurrence wins).
|
|
206
|
+
const existingIndex = new Map<string, ExternalNode>();
|
|
207
|
+
const indexTree = (nodes: ExternalNode[]): void => {
|
|
208
|
+
for (const n of nodes) {
|
|
209
|
+
const k = normKey(n.text);
|
|
210
|
+
if (k !== '' && !existingIndex.has(k)) existingIndex.set(k, n);
|
|
211
|
+
indexTree(n.children);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
indexTree(existingTree);
|
|
215
|
+
|
|
216
|
+
let newEntryLine = existingText.split(/\r?\n/).length; // approx. line for inserted content
|
|
217
|
+
|
|
218
|
+
const mergeNodes = (importNodes: ExternalNode[], targetChildren: ExternalNode[]): void => {
|
|
219
|
+
for (const imp of importNodes) {
|
|
220
|
+
const key = normKey(imp.text);
|
|
221
|
+
const exact = key !== '' ? existingIndex.get(key) : undefined;
|
|
222
|
+
|
|
223
|
+
if (exact !== undefined) {
|
|
224
|
+
// Matched by normalized text → conflict only when the wording differs.
|
|
225
|
+
if (exact.text !== imp.text) {
|
|
226
|
+
conflicts.push({
|
|
227
|
+
file: relName,
|
|
228
|
+
line: lineOfKey.get(key) ?? 0,
|
|
229
|
+
cansVersion: exact.text,
|
|
230
|
+
importVersion: imp.text,
|
|
231
|
+
resolution: strategy,
|
|
232
|
+
});
|
|
233
|
+
if (strategy === 'import-wins') exact.text = imp.text;
|
|
234
|
+
// cans-wins / ask: keep the CANS version
|
|
235
|
+
}
|
|
236
|
+
mergeNodes(imp.children, exact.children);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Near-match check against the siblings at this slot (word overlap).
|
|
241
|
+
const near = targetChildren.find(c => isNearMatch(c.text, imp.text));
|
|
242
|
+
if (near !== undefined) {
|
|
243
|
+
conflicts.push({
|
|
244
|
+
file: relName,
|
|
245
|
+
line: lineOfKey.get(normKey(near.text)) ?? 0,
|
|
246
|
+
cansVersion: near.text,
|
|
247
|
+
importVersion: imp.text,
|
|
248
|
+
resolution: strategy,
|
|
249
|
+
});
|
|
250
|
+
if (strategy === 'import-wins') near.text = imp.text;
|
|
251
|
+
mergeNodes(imp.children, near.children);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (strategy === 'ask') {
|
|
256
|
+
// ask = report, don't merge: the would-be addition is surfaced too,
|
|
257
|
+
// otherwise ask would silently drop new content with no trace.
|
|
258
|
+
conflicts.push({
|
|
259
|
+
file: relName,
|
|
260
|
+
line: ++newEntryLine,
|
|
261
|
+
cansVersion: '',
|
|
262
|
+
importVersion: imp.text,
|
|
263
|
+
resolution: 'ask',
|
|
264
|
+
});
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// New node → insert under the correct parent, re-index so deeper
|
|
269
|
+
// children can find it, then merge its subtree.
|
|
270
|
+
const inserted: ExternalNode = { ...imp, children: [] };
|
|
271
|
+
targetChildren.push(inserted);
|
|
272
|
+
if (key !== '') existingIndex.set(key, inserted);
|
|
273
|
+
mergeNodes(imp.children, inserted.children);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
mergeNodes(imported, existingTree);
|
|
278
|
+
|
|
279
|
+
if (strategy === 'ask') return { content: null, conflicts };
|
|
280
|
+
return { content: serializeToCans(existingTree), conflicts };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** §27/§28 (QA-09 D12): OPML/Dynalist exports carry the SOURCE SPEC FILENAME in
|
|
284
|
+
* `<head><title>` (e.g. `02-authentication.md`). When the title names a spec
|
|
285
|
+
* file it — not the first node's text — drives merge-target matching and
|
|
286
|
+
* new-file naming, so re-importing an edited export lands on the original
|
|
287
|
+
* file instead of silently forking. Other titles (e.g. "Project Backlog")
|
|
288
|
+
* fall back to first-node naming. */
|
|
289
|
+
const SPEC_TITLE_RE = /^\d{2}-[a-z0-9-]+(?:\.md)?$/i;
|
|
290
|
+
|
|
291
|
+
export async function run(args: string[]): Promise<ImportResult> {
|
|
292
|
+
const opts = parseImportArgs(args);
|
|
293
|
+
const fmt = opts.format.toLowerCase(); // §27 formats are lowercase; accept OPML/Obsidian casing
|
|
294
|
+
|
|
295
|
+
// §37/§27: invalid enum values are rejected, never silently defaulted (QA-05 F10).
|
|
296
|
+
if (opts.mergeStrategyRaw !== null && !(STRATEGIES as readonly string[]).includes(opts.mergeStrategyRaw)) {
|
|
297
|
+
return fail(fmt, opts.path,
|
|
298
|
+
`unknown merge strategy "${opts.mergeStrategyRaw}" — valid: cans-wins, import-wins, ask`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (opts.path === '') {
|
|
302
|
+
return fail(fmt, opts.path,
|
|
303
|
+
'usage: cans import <format> <path>\n Formats: opml, dynalist, logseq, obsidian');
|
|
304
|
+
}
|
|
305
|
+
if (!FORMATS.includes(fmt)) {
|
|
306
|
+
return fail(fmt, opts.path,
|
|
307
|
+
`unknown format "${opts.format}" — valid formats: opml, dynalist, logseq, obsidian`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const files = sourceFiles(opts.path, fmt);
|
|
311
|
+
if (files === null || files.length === 0) {
|
|
312
|
+
return fail(fmt, opts.path,
|
|
313
|
+
`source not found: ${opts.path}\n Check the path and try again.`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// §20/§36: --out overrides workspace discovery; otherwise a workspace is required.
|
|
317
|
+
let workspace: string;
|
|
318
|
+
if (opts.out !== null) {
|
|
319
|
+
workspace = opts.out;
|
|
320
|
+
if (!opts.dryRun) mkdirp(workspace);
|
|
321
|
+
} else {
|
|
322
|
+
const ws = resolveWorkspaceRoot();
|
|
323
|
+
if (ws === null) {
|
|
324
|
+
return fail(fmt, opts.path,
|
|
325
|
+
'no cans workspace found — run `cans init` first, or pass --out <dir>');
|
|
326
|
+
}
|
|
327
|
+
workspace = ws;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const newFiles: string[] = [];
|
|
331
|
+
const merged: string[] = [];
|
|
332
|
+
const conflicts: ImportConflict[] = [];
|
|
333
|
+
|
|
334
|
+
let nextNum = nextSpecNumber(workspace);
|
|
335
|
+
|
|
336
|
+
for (const src of files) {
|
|
337
|
+
let text = '';
|
|
338
|
+
try {
|
|
339
|
+
text = await Bun.file(src).text();
|
|
340
|
+
} catch {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// §27/§28 (QA-09 D12): use the export's source-filename title as the file
|
|
345
|
+
// identity when it names a spec file; otherwise first-node naming.
|
|
346
|
+
let titleBase: string | null = null;
|
|
347
|
+
if (fmt === 'opml' || fmt === 'dynalist') {
|
|
348
|
+
const title = parseOpmlTitle(text);
|
|
349
|
+
if (title !== null && SPEC_TITLE_RE.test(title)) {
|
|
350
|
+
titleBase = title.replace(/\.md$/i, '');
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// §27: fenced code blocks under bullets are extracted to overflow files
|
|
355
|
+
// before parsing, so their content survives as files + see: refs (QA-05 F5).
|
|
356
|
+
let overflow: OverflowExtraction[] = [];
|
|
357
|
+
let cleanText = text;
|
|
358
|
+
if (fmt === 'obsidian' || fmt === 'logseq') {
|
|
359
|
+
const baseSlug = slugify(basename(src).replace(/\.[^.]+$/, '')) || 'import';
|
|
360
|
+
const extracted = extractOverflowContent(text, baseSlug);
|
|
361
|
+
cleanText = extracted.cleanedSource;
|
|
362
|
+
overflow = extracted.extractions;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let imported: ExternalNode[];
|
|
366
|
+
try {
|
|
367
|
+
imported = parseSource(cleanText, fmt);
|
|
368
|
+
} catch (e) {
|
|
369
|
+
// e.g. non-XML garbage passed as .opml (QA-05 F12) — fail loudly, write nothing.
|
|
370
|
+
return fail(fmt, opts.path,
|
|
371
|
+
`invalid OPML in ${basename(src)} — ${(e as Error).message}`);
|
|
372
|
+
}
|
|
373
|
+
if (imported.length === 0) continue;
|
|
374
|
+
|
|
375
|
+
// Merge target: the export's source-file identity when available, else the
|
|
376
|
+
// first node's text (QA-09 D12 — `02-authentication.opml` must re-match
|
|
377
|
+
// 02-authentication.md, not slug the first node "sessions" into a fork).
|
|
378
|
+
const slug = titleBase !== null
|
|
379
|
+
? slugify(titleBase.replace(/^\d{2}-/, ''))
|
|
380
|
+
: slugify(imported[0].text);
|
|
381
|
+
if (slug === '') continue;
|
|
382
|
+
|
|
383
|
+
// Same-slug spec already present → merge; otherwise a new NN-slug.md file.
|
|
384
|
+
const existingRel = findExistingBySlug(workspace, slug);
|
|
385
|
+
if (existingRel !== null) {
|
|
386
|
+
const absTarget = join(workspace, existingRel);
|
|
387
|
+
const outcome = mergeInto(
|
|
388
|
+
await Bun.file(absTarget).text(),
|
|
389
|
+
imported,
|
|
390
|
+
opts.mergeStrategy,
|
|
391
|
+
existingRel,
|
|
392
|
+
);
|
|
393
|
+
conflicts.push(...outcome.conflicts);
|
|
394
|
+
if (outcome.content !== null) {
|
|
395
|
+
if (!opts.dryRun) {
|
|
396
|
+
await Bun.write(absTarget, canonicalizeRefTargets(outcome.content));
|
|
397
|
+
for (const ovf of overflow) {
|
|
398
|
+
const ovfAbs = join(workspace, ovf.overflowFile);
|
|
399
|
+
mkdirp(dirname(ovfAbs));
|
|
400
|
+
await Bun.write(ovfAbs, `${ovf.content}\n`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
merged.push(existingRel);
|
|
404
|
+
for (const ovf of overflow) newFiles.push(ovf.overflowFile);
|
|
405
|
+
}
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// New file: preserve the source spec's NN-name identity when the export
|
|
410
|
+
// carries it (QA-09 D12/D4), else the next free NN-first-node-slug.md.
|
|
411
|
+
const relName = titleBase !== null
|
|
412
|
+
? `${titleBase}.md`
|
|
413
|
+
: `${String(nextNum).padStart(2, '0')}-${slug}.md`;
|
|
414
|
+
const absTarget = join(workspace, relName);
|
|
415
|
+
|
|
416
|
+
const cansText = canonicalizeRefTargets(serializeToCans(imported));
|
|
417
|
+
if (!opts.dryRun) {
|
|
418
|
+
mkdirp(dirname(absTarget));
|
|
419
|
+
await Bun.write(absTarget, cansText);
|
|
420
|
+
for (const ovf of overflow) {
|
|
421
|
+
const ovfAbs = join(workspace, ovf.overflowFile);
|
|
422
|
+
mkdirp(dirname(ovfAbs));
|
|
423
|
+
await Bun.write(ovfAbs, `${ovf.content}\n`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
newFiles.push(relName);
|
|
427
|
+
for (const ovf of overflow) newFiles.push(ovf.overflowFile);
|
|
428
|
+
if (titleBase === null) nextNum++;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
ok: true, command: 'import', exitCode: 0,
|
|
433
|
+
format: fmt, source: opts.path, newFiles, merged, conflicts,
|
|
434
|
+
dryRun: opts.dryRun || undefined,
|
|
435
|
+
};
|
|
436
|
+
}
|