@openparachute/vault 0.4.4-rc.14 → 0.4.5
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/core/src/core.test.ts +221 -0
- package/core/src/mcp.ts +56 -6
- package/core/src/notes.ts +185 -15
- package/core/src/portable-md.test.ts +531 -0
- package/core/src/portable-md.ts +508 -19
- package/core/src/schema.ts +61 -3
- package/core/src/store.ts +5 -4
- package/core/src/types.ts +27 -3
- package/core/src/wikilinks.ts +74 -14
- package/package.json +1 -1
- package/src/published.test.ts +17 -0
- package/src/routes.ts +121 -3
- package/src/vault.test.ts +175 -0
|
@@ -31,8 +31,12 @@ import {
|
|
|
31
31
|
noteToPortable,
|
|
32
32
|
parseFrontmatter,
|
|
33
33
|
portableExportFilePath,
|
|
34
|
+
probeCaseSensitive,
|
|
34
35
|
SIDECAR_DIR,
|
|
36
|
+
NOTES_META_DIR,
|
|
37
|
+
supportsInlineFrontmatter,
|
|
35
38
|
toPortableMarkdown,
|
|
39
|
+
toSidecarYaml,
|
|
36
40
|
type PortableNote,
|
|
37
41
|
} from "./portable-md.js";
|
|
38
42
|
|
|
@@ -294,6 +298,25 @@ describe("portableExportFilePath", () => {
|
|
|
294
298
|
id: "01HABC", content: "", created_at: "2026-05-12T00:00:00.000Z",
|
|
295
299
|
})).toBe("_unpathed/01HABC.md");
|
|
296
300
|
});
|
|
301
|
+
|
|
302
|
+
it("honors the note's extension for pathless notes (vault#329 F4)", () => {
|
|
303
|
+
expect(portableExportFilePath({
|
|
304
|
+
id: "01HXCSV",
|
|
305
|
+
content: "a,b\n1,2",
|
|
306
|
+
created_at: "2026-05-12T00:00:00.000Z",
|
|
307
|
+
extension: "csv",
|
|
308
|
+
})).toBe("_unpathed/01HXCSV.csv");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("honors the note's extension for pathed notes (vault#329 F4)", () => {
|
|
312
|
+
expect(portableExportFilePath({
|
|
313
|
+
id: "x",
|
|
314
|
+
content: "",
|
|
315
|
+
created_at: "2026-05-12T00:00:00.000Z",
|
|
316
|
+
path: "Inbox/y",
|
|
317
|
+
extension: "mdx",
|
|
318
|
+
})).toBe("Inbox/y.mdx");
|
|
319
|
+
});
|
|
297
320
|
});
|
|
298
321
|
|
|
299
322
|
// ---------------------------------------------------------------------------
|
|
@@ -1021,3 +1044,511 @@ note body
|
|
|
1021
1044
|
expect(existsSync(wouldBeEscape)).toBe(false);
|
|
1022
1045
|
});
|
|
1023
1046
|
});
|
|
1047
|
+
|
|
1048
|
+
// ---------------------------------------------------------------------------
|
|
1049
|
+
// File-extension support — non-markdown notes (vault#328)
|
|
1050
|
+
// ---------------------------------------------------------------------------
|
|
1051
|
+
//
|
|
1052
|
+
// Two pinned behaviors per the design issue:
|
|
1053
|
+
// 1. supportsInlineFrontmatter: md + mdx return true; everything else
|
|
1054
|
+
// returns false.
|
|
1055
|
+
// 2. Round-trip integration: vault containing csv/yaml/json/mdx/empty
|
|
1056
|
+
// notes survives export → blow-away import → re-export byte-equiv.
|
|
1057
|
+
|
|
1058
|
+
describe("supportsInlineFrontmatter (vault#328)", () => {
|
|
1059
|
+
it("returns true for md and mdx", () => {
|
|
1060
|
+
expect(supportsInlineFrontmatter("md")).toBe(true);
|
|
1061
|
+
expect(supportsInlineFrontmatter("mdx")).toBe(true);
|
|
1062
|
+
// Case-insensitive — guard against caller-supplied 'MD'.
|
|
1063
|
+
expect(supportsInlineFrontmatter("MD")).toBe(true);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
it("returns false for sidecar-required formats", () => {
|
|
1067
|
+
expect(supportsInlineFrontmatter("csv")).toBe(false);
|
|
1068
|
+
expect(supportsInlineFrontmatter("yaml")).toBe(false);
|
|
1069
|
+
expect(supportsInlineFrontmatter("json")).toBe(false);
|
|
1070
|
+
expect(supportsInlineFrontmatter("txt")).toBe(false);
|
|
1071
|
+
expect(supportsInlineFrontmatter("org")).toBe(false); // not yet in the set
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
describe("toPortableMarkdown — extension awareness (vault#328)", () => {
|
|
1076
|
+
it(".md note still gets inline frontmatter", () => {
|
|
1077
|
+
const out = toPortableMarkdown({
|
|
1078
|
+
id: "1",
|
|
1079
|
+
path: "Inbox/a",
|
|
1080
|
+
extension: "md",
|
|
1081
|
+
content: "hello\n",
|
|
1082
|
+
created_at: "2026-05-15T00:00:00.000Z",
|
|
1083
|
+
});
|
|
1084
|
+
expect(out.startsWith("---\n")).toBe(true);
|
|
1085
|
+
expect(out).toContain("id: '1'");
|
|
1086
|
+
expect(out).toContain("hello");
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it(".mdx note also gets inline frontmatter", () => {
|
|
1090
|
+
const out = toPortableMarkdown({
|
|
1091
|
+
id: "1",
|
|
1092
|
+
path: "Components/Card",
|
|
1093
|
+
extension: "mdx",
|
|
1094
|
+
content: "import X from './x';\n\n<X/>\n",
|
|
1095
|
+
created_at: "2026-05-15T00:00:00.000Z",
|
|
1096
|
+
});
|
|
1097
|
+
expect(out.startsWith("---\n")).toBe(true);
|
|
1098
|
+
expect(out).toContain("extension: mdx");
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it(".csv note returns raw content — no frontmatter prepend", () => {
|
|
1102
|
+
const out = toPortableMarkdown({
|
|
1103
|
+
id: "1",
|
|
1104
|
+
path: "Tabular/budget",
|
|
1105
|
+
extension: "csv",
|
|
1106
|
+
content: "month,total\n2026-01,9000\n",
|
|
1107
|
+
created_at: "2026-05-15T00:00:00.000Z",
|
|
1108
|
+
});
|
|
1109
|
+
expect(out.startsWith("---\n")).toBe(false);
|
|
1110
|
+
expect(out).toBe("month,total\n2026-01,9000\n");
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
it("toSidecarYaml emits the same key set as inline frontmatter, always includes extension", () => {
|
|
1114
|
+
const sidecar = toSidecarYaml({
|
|
1115
|
+
id: "abc",
|
|
1116
|
+
path: "Tabular/budget",
|
|
1117
|
+
extension: "csv",
|
|
1118
|
+
tags: ["budget"],
|
|
1119
|
+
content: "irrelevant — sidecar carries metadata only",
|
|
1120
|
+
metadata: { fiscal_year: 2026 },
|
|
1121
|
+
created_at: "2026-05-15T00:00:00.000Z",
|
|
1122
|
+
updated_at: "2026-05-15T00:00:00.000Z",
|
|
1123
|
+
});
|
|
1124
|
+
expect(sidecar).toContain("id: abc");
|
|
1125
|
+
expect(sidecar).toContain("path: Tabular/budget");
|
|
1126
|
+
expect(sidecar).toContain("extension: csv");
|
|
1127
|
+
expect(sidecar).toContain("tags:");
|
|
1128
|
+
expect(sidecar).toContain("budget");
|
|
1129
|
+
expect(sidecar).toContain("fiscal_year");
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
describe("portable-md non-markdown round-trip (vault#328)", async () => {
|
|
1134
|
+
const tmpBase = join(tmpdir(), "parachute-portable-ext");
|
|
1135
|
+
let store: SqliteStore;
|
|
1136
|
+
|
|
1137
|
+
beforeEach(() => {
|
|
1138
|
+
try { rmSync(tmpBase, { recursive: true }); } catch {}
|
|
1139
|
+
mkdirSync(tmpBase, { recursive: true });
|
|
1140
|
+
store = new SqliteStore(new Database(":memory:"));
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it("csv/yaml/json/mdx/empty notes survive export → blow-away import → byte-equivalent re-export", async () => {
|
|
1144
|
+
// Seed a vault that hits every extension axis the brief calls out:
|
|
1145
|
+
// - .csv (sidecar-required, non-empty)
|
|
1146
|
+
// - .yaml (sidecar-required, non-empty)
|
|
1147
|
+
// - .json (sidecar-required, non-empty)
|
|
1148
|
+
// - .mdx (frontmatter-compatible, non-empty)
|
|
1149
|
+
// - .csv empty-content (the vault#323 edge case, vault#328 sidecar path)
|
|
1150
|
+
// - .md (back-compat baseline — same shape as pre-vault#328)
|
|
1151
|
+
await store.createNote("month,total\n2026-01,9000\n", {
|
|
1152
|
+
id: "csv-1",
|
|
1153
|
+
path: "Tabular/budget",
|
|
1154
|
+
extension: "csv",
|
|
1155
|
+
tags: ["budget"],
|
|
1156
|
+
metadata: { fiscal_year: 2026, currency: "USD" },
|
|
1157
|
+
});
|
|
1158
|
+
await store.createNote("- one\n- two\n", {
|
|
1159
|
+
id: "yaml-1",
|
|
1160
|
+
path: "Config/options",
|
|
1161
|
+
extension: "yaml",
|
|
1162
|
+
});
|
|
1163
|
+
await store.createNote(`{"k":1}\n`, {
|
|
1164
|
+
id: "json-1",
|
|
1165
|
+
path: "Data/sample",
|
|
1166
|
+
extension: "json",
|
|
1167
|
+
});
|
|
1168
|
+
await store.createNote("import X from './x';\n\n<X/>\n", {
|
|
1169
|
+
id: "mdx-1",
|
|
1170
|
+
path: "Components/Card",
|
|
1171
|
+
extension: "mdx",
|
|
1172
|
+
tags: ["component"],
|
|
1173
|
+
});
|
|
1174
|
+
await store.createNote("", {
|
|
1175
|
+
id: "csv-empty",
|
|
1176
|
+
path: "Tabular/skeleton",
|
|
1177
|
+
extension: "csv",
|
|
1178
|
+
});
|
|
1179
|
+
await store.createNote("plain markdown body\n", {
|
|
1180
|
+
id: "md-1",
|
|
1181
|
+
path: "Inbox/note",
|
|
1182
|
+
tags: ["inbox"],
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
// Export A.
|
|
1186
|
+
const outA = join(tmpBase, "outA");
|
|
1187
|
+
const statsA = await exportVaultToDir(store, {
|
|
1188
|
+
outDir: outA,
|
|
1189
|
+
vaultName: "test",
|
|
1190
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1191
|
+
});
|
|
1192
|
+
expect(statsA.notes).toBe(6);
|
|
1193
|
+
// Four sidecar-required notes (csv ×2, yaml, json) → four sidecars.
|
|
1194
|
+
// md + mdx carry frontmatter inline so they don't get sidecars.
|
|
1195
|
+
expect(statsA.sidecars).toBe(4);
|
|
1196
|
+
|
|
1197
|
+
// Sidecar files exist with the right basenames (note ids).
|
|
1198
|
+
const sidecarDir = join(outA, SIDECAR_DIR, NOTES_META_DIR);
|
|
1199
|
+
expect(readdirSync(sidecarDir).sort()).toEqual([
|
|
1200
|
+
"csv-1.yaml",
|
|
1201
|
+
"csv-empty.yaml",
|
|
1202
|
+
"json-1.yaml",
|
|
1203
|
+
"yaml-1.yaml",
|
|
1204
|
+
]);
|
|
1205
|
+
|
|
1206
|
+
// Content files at the user-visible paths with the right suffixes.
|
|
1207
|
+
expect(existsSync(join(outA, "Tabular/budget.csv"))).toBe(true);
|
|
1208
|
+
expect(existsSync(join(outA, "Config/options.yaml"))).toBe(true);
|
|
1209
|
+
expect(existsSync(join(outA, "Data/sample.json"))).toBe(true);
|
|
1210
|
+
expect(existsSync(join(outA, "Components/Card.mdx"))).toBe(true);
|
|
1211
|
+
expect(existsSync(join(outA, "Tabular/skeleton.csv"))).toBe(true);
|
|
1212
|
+
expect(existsSync(join(outA, "Inbox/note.md"))).toBe(true);
|
|
1213
|
+
|
|
1214
|
+
// .csv content is RAW — no `---` frontmatter prepend.
|
|
1215
|
+
const csvFile = readFileSync(join(outA, "Tabular/budget.csv"), "utf-8");
|
|
1216
|
+
expect(csvFile.startsWith("---")).toBe(false);
|
|
1217
|
+
expect(csvFile).toBe("month,total\n2026-01,9000\n");
|
|
1218
|
+
|
|
1219
|
+
// .mdx content has inline frontmatter (same as .md).
|
|
1220
|
+
const mdxFile = readFileSync(join(outA, "Components/Card.mdx"), "utf-8");
|
|
1221
|
+
expect(mdxFile.startsWith("---\n")).toBe(true);
|
|
1222
|
+
expect(mdxFile).toContain("extension: mdx");
|
|
1223
|
+
|
|
1224
|
+
// Blow-away import into a fresh store.
|
|
1225
|
+
const restored = new SqliteStore(new Database(":memory:"));
|
|
1226
|
+
const importStats = await importPortableVault(restored, { inDir: outA, blowAway: true });
|
|
1227
|
+
expect(importStats.notes_created).toBe(6);
|
|
1228
|
+
|
|
1229
|
+
// Verify each note round-tripped its content + extension.
|
|
1230
|
+
const csvRestored = await restored.getNote("csv-1");
|
|
1231
|
+
expect(csvRestored).not.toBeNull();
|
|
1232
|
+
expect(csvRestored!.content).toBe("month,total\n2026-01,9000\n");
|
|
1233
|
+
expect(csvRestored!.extension).toBe("csv");
|
|
1234
|
+
expect(csvRestored!.metadata).toEqual({ fiscal_year: 2026, currency: "USD" });
|
|
1235
|
+
expect(csvRestored!.tags).toContain("budget");
|
|
1236
|
+
|
|
1237
|
+
const emptyCsv = await restored.getNote("csv-empty");
|
|
1238
|
+
expect(emptyCsv!.content).toBe("");
|
|
1239
|
+
expect(emptyCsv!.extension).toBe("csv");
|
|
1240
|
+
expect(emptyCsv!.path).toBe("Tabular/skeleton");
|
|
1241
|
+
|
|
1242
|
+
const mdxRestored = await restored.getNote("mdx-1");
|
|
1243
|
+
expect(mdxRestored!.extension).toBe("mdx");
|
|
1244
|
+
expect(mdxRestored!.content).toBe("import X from './x';\n\n<X/>\n");
|
|
1245
|
+
|
|
1246
|
+
const mdRestored = await restored.getNote("md-1");
|
|
1247
|
+
expect(mdRestored!.extension).toBe("md");
|
|
1248
|
+
expect(mdRestored!.tags).toContain("inbox");
|
|
1249
|
+
|
|
1250
|
+
// Re-export B from the restored store.
|
|
1251
|
+
const outB = join(tmpBase, "outB");
|
|
1252
|
+
await exportVaultToDir(restored, {
|
|
1253
|
+
outDir: outB,
|
|
1254
|
+
vaultName: "test",
|
|
1255
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
// Byte-equivalence — every file in outA matches outB.
|
|
1259
|
+
const compareTree = (a: string, b: string, prefix = "") => {
|
|
1260
|
+
const aEntries = readdirSync(a).sort();
|
|
1261
|
+
const bEntries = readdirSync(b).sort();
|
|
1262
|
+
expect(bEntries).toEqual(aEntries);
|
|
1263
|
+
for (const entry of aEntries) {
|
|
1264
|
+
const aPath = join(a, entry);
|
|
1265
|
+
const bPath = join(b, entry);
|
|
1266
|
+
const aStat = statSync(aPath);
|
|
1267
|
+
const bStat = statSync(bPath);
|
|
1268
|
+
expect(bStat.isDirectory()).toBe(aStat.isDirectory());
|
|
1269
|
+
if (aStat.isDirectory()) {
|
|
1270
|
+
compareTree(aPath, bPath, prefix + entry + "/");
|
|
1271
|
+
} else {
|
|
1272
|
+
const aBuf = readFileSync(aPath, "utf-8");
|
|
1273
|
+
const bBuf = readFileSync(bPath, "utf-8");
|
|
1274
|
+
if (aBuf !== bBuf) {
|
|
1275
|
+
// eslint-disable-next-line no-console
|
|
1276
|
+
console.error(`drift at ${prefix}${entry}:\n--- outA ---\n${aBuf}\n--- outB ---\n${bBuf}`);
|
|
1277
|
+
}
|
|
1278
|
+
expect(bBuf).toBe(aBuf);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
compareTree(outA, outB);
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
it("records orphan sidecars in ImportStats.skipped_sidecars (vault#330 S2)", async () => {
|
|
1286
|
+
// Build a portable-md export by hand with an orphan sidecar: a
|
|
1287
|
+
// sidecar YAML at .parachute/notes-meta/<id>.yaml whose
|
|
1288
|
+
// (path, extension) doesn't point to any real content file on
|
|
1289
|
+
// disk. Import should record it in skipped_sidecars without
|
|
1290
|
+
// crashing.
|
|
1291
|
+
const outDir = join(tmpBase, "orphan-sidecar");
|
|
1292
|
+
mkdirSync(join(outDir, SIDECAR_DIR, NOTES_META_DIR), { recursive: true });
|
|
1293
|
+
writeFileSync(
|
|
1294
|
+
join(outDir, SIDECAR_DIR, "vault.yaml"),
|
|
1295
|
+
"export_format_version: 1\nexported_at: '2026-05-15T00:00:00.000Z'\n",
|
|
1296
|
+
);
|
|
1297
|
+
writeFileSync(
|
|
1298
|
+
join(outDir, SIDECAR_DIR, NOTES_META_DIR, "orphan-1.yaml"),
|
|
1299
|
+
"id: orphan-1\npath: Tabular/missing\nextension: csv\ncreated_at: '2026-05-15T00:00:00.000Z'\nupdated_at: '2026-05-15T00:00:00.000Z'\n",
|
|
1300
|
+
);
|
|
1301
|
+
|
|
1302
|
+
const restored = new SqliteStore(new Database(":memory:"));
|
|
1303
|
+
const stats = await importPortableVault(restored, { inDir: outDir });
|
|
1304
|
+
expect(stats.notes_created).toBe(0);
|
|
1305
|
+
expect(stats.skipped_sidecars).toHaveLength(1);
|
|
1306
|
+
expect(stats.skipped_sidecars[0]!.sidecar_id).toBe("orphan-1");
|
|
1307
|
+
expect(stats.skipped_sidecars[0]!.expected_path).toBe("Tabular/missing");
|
|
1308
|
+
expect(stats.skipped_sidecars[0]!.expected_extension).toBe("csv");
|
|
1309
|
+
expect(stats.skipped_sidecars[0]!.reason).toMatch(/no content file/);
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
it("skipped_sidecars stays empty on a clean export-then-import", async () => {
|
|
1313
|
+
// Sanity pin: when every sidecar pairs with a content file, the
|
|
1314
|
+
// leftover-drain produces no entries.
|
|
1315
|
+
await store.createNote("month,total\n2026-01,9000", {
|
|
1316
|
+
id: "csv-1",
|
|
1317
|
+
path: "Tabular/budget",
|
|
1318
|
+
extension: "csv",
|
|
1319
|
+
});
|
|
1320
|
+
const outDir = join(tmpBase, "clean-sidecars");
|
|
1321
|
+
await exportVaultToDir(store, {
|
|
1322
|
+
outDir,
|
|
1323
|
+
vaultName: "test",
|
|
1324
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1325
|
+
});
|
|
1326
|
+
const restored = new SqliteStore(new Database(":memory:"));
|
|
1327
|
+
const stats = await importPortableVault(restored, { inDir: outDir, blowAway: true });
|
|
1328
|
+
expect(stats.skipped_sidecars).toEqual([]);
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
it("import refuses content files lacking a sidecar (orphaned non-md file)", async () => {
|
|
1332
|
+
// Build a minimal portable-md directory by hand: a valid vault.yaml
|
|
1333
|
+
// + a .csv content file with NO matching sidecar. The importer
|
|
1334
|
+
// should skip the orphaned file rather than crashing or creating
|
|
1335
|
+
// a sidecar-less note.
|
|
1336
|
+
const outDir = join(tmpBase, "orphan");
|
|
1337
|
+
mkdirSync(join(outDir, SIDECAR_DIR), { recursive: true });
|
|
1338
|
+
writeFileSync(
|
|
1339
|
+
join(outDir, SIDECAR_DIR, "vault.yaml"),
|
|
1340
|
+
"export_format_version: 1\nexported_at: '2026-05-15T00:00:00.000Z'\n",
|
|
1341
|
+
);
|
|
1342
|
+
mkdirSync(join(outDir, "Tabular"), { recursive: true });
|
|
1343
|
+
writeFileSync(join(outDir, "Tabular/orphan.csv"), "a,b\n1,2\n");
|
|
1344
|
+
|
|
1345
|
+
const restored = new SqliteStore(new Database(":memory:"));
|
|
1346
|
+
const stats = await importPortableVault(restored, { inDir: outDir });
|
|
1347
|
+
// No sidecar → no DB row.
|
|
1348
|
+
expect(stats.notes_created).toBe(0);
|
|
1349
|
+
});
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
// ---------------------------------------------------------------------------
|
|
1353
|
+
// Wikilink ambiguity policy (vault#328)
|
|
1354
|
+
// ---------------------------------------------------------------------------
|
|
1355
|
+
//
|
|
1356
|
+
// When two notes share a path differing only by extension (e.g. `Foo.md`
|
|
1357
|
+
// and `Foo.csv`), `[[Foo]]` is ambiguous. The resolver must:
|
|
1358
|
+
// - refuse to resolve the bare form and record it as unresolved
|
|
1359
|
+
// - resolve `[[Foo.md]]` and `[[Foo.csv]]` to their respective notes
|
|
1360
|
+
|
|
1361
|
+
describe("wikilink ambiguity across extensions (vault#328)", async () => {
|
|
1362
|
+
it("refuses ambiguous bare-form wikilinks when path collides on extension", async () => {
|
|
1363
|
+
const store = new SqliteStore(new Database(":memory:"));
|
|
1364
|
+
const md = await store.createNote("# MD note", { path: "Foo", id: "foo-md" });
|
|
1365
|
+
const csv = await store.createNote("a,b\n1,2", { path: "Foo", extension: "csv", id: "foo-csv" });
|
|
1366
|
+
// Sanity — both rows landed under the composite uniqueness key.
|
|
1367
|
+
expect(md.path).toBe("Foo");
|
|
1368
|
+
expect(csv.path).toBe("Foo");
|
|
1369
|
+
|
|
1370
|
+
// A third note linking to bare `[[Foo]]` should be UNRESOLVED.
|
|
1371
|
+
await store.createNote("see [[Foo]]", { id: "linker", path: "Linker" });
|
|
1372
|
+
const outboundLinks = await store.getLinks("linker", { direction: "outbound" });
|
|
1373
|
+
expect(outboundLinks).toHaveLength(0);
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
it("resolves explicit-extension wikilinks unambiguously", async () => {
|
|
1377
|
+
const store = new SqliteStore(new Database(":memory:"));
|
|
1378
|
+
await store.createNote("# MD note", { path: "Foo", id: "foo-md" });
|
|
1379
|
+
await store.createNote("a,b\n1,2", { path: "Foo", extension: "csv", id: "foo-csv" });
|
|
1380
|
+
|
|
1381
|
+
await store.createNote("see [[Foo.csv]]", { id: "linker", path: "Linker" });
|
|
1382
|
+
const outbound = await store.getLinks("linker", { direction: "outbound" });
|
|
1383
|
+
expect(outbound).toHaveLength(1);
|
|
1384
|
+
expect(outbound[0]!.targetId).toBe("foo-csv");
|
|
1385
|
+
});
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
// ---------------------------------------------------------------------------
|
|
1389
|
+
// Case-collision detection + auto-disambiguation (vault#327)
|
|
1390
|
+
// ---------------------------------------------------------------------------
|
|
1391
|
+
//
|
|
1392
|
+
// macOS APFS-default + Windows NTFS-default + FAT/exFAT are case-
|
|
1393
|
+
// insensitive — two notes whose paths differ only by case collapse
|
|
1394
|
+
// into one file on naive export. The fix probes the filesystem then
|
|
1395
|
+
// either ships as-is (case-sensitive) or disambiguates with an
|
|
1396
|
+
// `__<id-short>` filename suffix (case-insensitive). The note's
|
|
1397
|
+
// stored `path` stays canonical; only the on-disk filename is suffixed.
|
|
1398
|
+
|
|
1399
|
+
describe("case-collision detection (vault#327)", async () => {
|
|
1400
|
+
const tmpBase = join(tmpdir(), "parachute-portable-case");
|
|
1401
|
+
let store: SqliteStore;
|
|
1402
|
+
|
|
1403
|
+
beforeEach(() => {
|
|
1404
|
+
try { rmSync(tmpBase, { recursive: true }); } catch {}
|
|
1405
|
+
mkdirSync(tmpBase, { recursive: true });
|
|
1406
|
+
store = new SqliteStore(new Database(":memory:"));
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
it("probeCaseSensitive runs cleanly and returns a boolean", () => {
|
|
1410
|
+
// Documenting behavior rather than asserting a specific value:
|
|
1411
|
+
// macOS dev environments + most Linux CI runners produce different
|
|
1412
|
+
// results. We just pin that the probe is well-formed (doesn't throw,
|
|
1413
|
+
// returns boolean, cleans up its tempfile).
|
|
1414
|
+
const result = probeCaseSensitive(tmpBase);
|
|
1415
|
+
expect(typeof result).toBe("boolean");
|
|
1416
|
+
// After the probe runs the dir should contain no leftover probe files.
|
|
1417
|
+
const leftovers = readdirSync(tmpBase).filter((e) => e.includes("_parachute_cs_probe_"));
|
|
1418
|
+
expect(leftovers).toEqual([]);
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it("ships as-is when FS is case-sensitive (override=true)", async () => {
|
|
1422
|
+
// Two notes with case-only-differing paths. With the case-sensitive
|
|
1423
|
+
// override, both files land at their canonical paths — no
|
|
1424
|
+
// disambiguation, no collisions in stats.
|
|
1425
|
+
await store.createNote("# in Balance", {
|
|
1426
|
+
id: "2025-05-26-09-15-42-aaaaaa",
|
|
1427
|
+
path: "Journal/2025-05-26 Technology in Balance",
|
|
1428
|
+
});
|
|
1429
|
+
await store.createNote("# in balance", {
|
|
1430
|
+
id: "2025-05-26-09-15-42-bbbbbb",
|
|
1431
|
+
path: "Journal/2025-05-26 Technology in balance",
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
const outDir = join(tmpBase, "cs-on");
|
|
1435
|
+
const stats = await exportVaultToDir(store, {
|
|
1436
|
+
outDir,
|
|
1437
|
+
vaultName: "test",
|
|
1438
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1439
|
+
caseSensitiveOverride: true,
|
|
1440
|
+
});
|
|
1441
|
+
expect(stats.notes).toBe(2);
|
|
1442
|
+
expect(stats.case_insensitive_fs).toBe(false);
|
|
1443
|
+
expect(stats.disambiguated_paths).toHaveLength(0);
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
it("disambiguates colliding paths on case-insensitive FS (override=false)", async () => {
|
|
1447
|
+
// Same fixture as above; force the case-insensitive code path. The
|
|
1448
|
+
// second note (deterministic order: queryNotes sorts ASC on
|
|
1449
|
+
// created_at — the IDs sort lexicographically so 'aaaaaa' lands
|
|
1450
|
+
// first, 'bbbbbb' second) gets its filename suffixed.
|
|
1451
|
+
await store.createNote("# in Balance", {
|
|
1452
|
+
id: "2025-05-26-09-15-42-aaaaaa",
|
|
1453
|
+
path: "Journal/2025-05-26 Technology in Balance",
|
|
1454
|
+
});
|
|
1455
|
+
await store.createNote("# in balance", {
|
|
1456
|
+
id: "2025-05-26-09-15-42-bbbbbb",
|
|
1457
|
+
path: "Journal/2025-05-26 Technology in balance",
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
const outDir = join(tmpBase, "ci-on");
|
|
1461
|
+
const stats = await exportVaultToDir(store, {
|
|
1462
|
+
outDir,
|
|
1463
|
+
vaultName: "test",
|
|
1464
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1465
|
+
caseSensitiveOverride: false,
|
|
1466
|
+
});
|
|
1467
|
+
expect(stats.notes).toBe(2);
|
|
1468
|
+
expect(stats.case_insensitive_fs).toBe(true);
|
|
1469
|
+
expect(stats.disambiguated_paths).toHaveLength(1);
|
|
1470
|
+
expect(stats.disambiguated_paths[0]!.note_id).toBe("2025-05-26-09-15-42-bbbbbb");
|
|
1471
|
+
expect(stats.disambiguated_paths[0]!.disambiguated_filename).toMatch(/__2025-05-/);
|
|
1472
|
+
|
|
1473
|
+
// Both files exist on disk under their respective filenames.
|
|
1474
|
+
expect(existsSync(join(outDir, "Journal/2025-05-26 Technology in Balance.md"))).toBe(true);
|
|
1475
|
+
expect(existsSync(join(outDir, stats.disambiguated_paths[0]!.disambiguated_filename))).toBe(true);
|
|
1476
|
+
|
|
1477
|
+
// The disambiguated file's inline frontmatter still carries the
|
|
1478
|
+
// canonical (original) path, NOT the disambiguated filename — that
|
|
1479
|
+
// way an import on a case-sensitive filesystem (or a future re-
|
|
1480
|
+
// export on a case-sensitive FS) recovers the truth.
|
|
1481
|
+
const disambigFile = readFileSync(
|
|
1482
|
+
join(outDir, stats.disambiguated_paths[0]!.disambiguated_filename),
|
|
1483
|
+
"utf-8",
|
|
1484
|
+
);
|
|
1485
|
+
expect(disambigFile).toContain("path: Journal/2025-05-26 Technology in balance");
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
it("disambiguated content round-trips through import (md inline frontmatter path)", async () => {
|
|
1489
|
+
await store.createNote("# upper", {
|
|
1490
|
+
id: "2025-05-26-09-15-42-aaaaaa",
|
|
1491
|
+
path: "Journal/Same-Case Different",
|
|
1492
|
+
});
|
|
1493
|
+
await store.createNote("# lower", {
|
|
1494
|
+
id: "2025-05-26-09-15-42-bbbbbb",
|
|
1495
|
+
path: "Journal/same-case different",
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
const outDir = join(tmpBase, "rt-md");
|
|
1499
|
+
const stats = await exportVaultToDir(store, {
|
|
1500
|
+
outDir,
|
|
1501
|
+
vaultName: "test",
|
|
1502
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1503
|
+
caseSensitiveOverride: false,
|
|
1504
|
+
});
|
|
1505
|
+
expect(stats.disambiguated_paths).toHaveLength(1);
|
|
1506
|
+
|
|
1507
|
+
const restored = new SqliteStore(new Database(":memory:"));
|
|
1508
|
+
const importStats = await importPortableVault(restored, { inDir: outDir, blowAway: true });
|
|
1509
|
+
expect(importStats.notes_created).toBe(2);
|
|
1510
|
+
|
|
1511
|
+
// Both notes recovered their canonical paths from the frontmatter.
|
|
1512
|
+
const upper = await restored.getNote("2025-05-26-09-15-42-aaaaaa");
|
|
1513
|
+
const lower = await restored.getNote("2025-05-26-09-15-42-bbbbbb");
|
|
1514
|
+
expect(upper!.path).toBe("Journal/Same-Case Different");
|
|
1515
|
+
expect(lower!.path).toBe("Journal/same-case different");
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
it("disambiguated sidecar-required content round-trips (csv with sidecar lookup fallback)", async () => {
|
|
1519
|
+
// The disambig fallback in the import loop kicks in: the sidecar
|
|
1520
|
+
// is keyed by canonical (path, ext) so the walker's
|
|
1521
|
+
// `<base>__<id8>.csv` filename doesn't match the index — but the
|
|
1522
|
+
// id-prefix lookup recovers the sidecar.
|
|
1523
|
+
await store.createNote("month,total\n2026-01,9000", {
|
|
1524
|
+
id: "2025-05-26-09-15-42-aaaaaa",
|
|
1525
|
+
path: "Tabular/Budget-2026",
|
|
1526
|
+
extension: "csv",
|
|
1527
|
+
});
|
|
1528
|
+
await store.createNote("month,total\n2026-01,1", {
|
|
1529
|
+
id: "2025-05-26-09-15-42-bbbbbb",
|
|
1530
|
+
path: "Tabular/budget-2026",
|
|
1531
|
+
extension: "csv",
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
const outDir = join(tmpBase, "rt-csv");
|
|
1535
|
+
const stats = await exportVaultToDir(store, {
|
|
1536
|
+
outDir,
|
|
1537
|
+
vaultName: "test",
|
|
1538
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1539
|
+
caseSensitiveOverride: false,
|
|
1540
|
+
});
|
|
1541
|
+
expect(stats.disambiguated_paths).toHaveLength(1);
|
|
1542
|
+
|
|
1543
|
+
const restored = new SqliteStore(new Database(":memory:"));
|
|
1544
|
+
const importStats = await importPortableVault(restored, { inDir: outDir, blowAway: true });
|
|
1545
|
+
expect(importStats.notes_created).toBe(2);
|
|
1546
|
+
|
|
1547
|
+
const upper = await restored.getNote("2025-05-26-09-15-42-aaaaaa");
|
|
1548
|
+
const lower = await restored.getNote("2025-05-26-09-15-42-bbbbbb");
|
|
1549
|
+
expect(upper!.path).toBe("Tabular/Budget-2026");
|
|
1550
|
+
expect(upper!.content).toBe("month,total\n2026-01,9000");
|
|
1551
|
+
expect(lower!.path).toBe("Tabular/budget-2026");
|
|
1552
|
+
expect(lower!.content).toBe("month,total\n2026-01,1");
|
|
1553
|
+
});
|
|
1554
|
+
});
|