@openparachute/vault 0.4.4-rc.12 → 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 +348 -60
- package/core/src/mcp.ts +61 -32
- package/core/src/notes.ts +187 -81
- package/core/src/portable-md.test.ts +554 -1
- 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/cli.ts +17 -0
- package/src/import-daemon-busy.test.ts +109 -0
- package/src/published.test.ts +17 -0
- package/src/routes.ts +136 -48
- package/src/vault.test.ts +294 -32
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -777,6 +800,10 @@ describe("portable-md round-trip — byte-equivalent re-export after blow-away i
|
|
|
777
800
|
// - Typed links (non-wikilink).
|
|
778
801
|
// - Multi-line metadata (vault#317 F1 path).
|
|
779
802
|
// - One note edited (created_at ≠ updated_at).
|
|
803
|
+
// - Empty-content note (vault#323) — skeleton/draft shape that real
|
|
804
|
+
// vaults legitimately carry. Pins the round-trip regression that
|
|
805
|
+
// blocked stable promotion when the EMPTY_NOTE guard was still
|
|
806
|
+
// rejecting these on import.
|
|
780
807
|
await store.upsertTagSchema("project", {
|
|
781
808
|
description: "A long-running effort",
|
|
782
809
|
fields: { status: { type: "string", enum: ["active", "done"] } },
|
|
@@ -797,6 +824,14 @@ describe("portable-md round-trip — byte-equivalent re-export after blow-away i
|
|
|
797
824
|
tags: ["project"],
|
|
798
825
|
});
|
|
799
826
|
await store.createNote("unpathed jot", { id: "01HX003" });
|
|
827
|
+
// Empty-content note with a path — the skeleton/organizing-only
|
|
828
|
+
// shape from vault#323. Export emits it as frontmatter + empty body;
|
|
829
|
+
// import must accept it.
|
|
830
|
+
await store.createNote("", {
|
|
831
|
+
id: "01HX004",
|
|
832
|
+
path: "Inbox/skeleton",
|
|
833
|
+
tags: ["project"],
|
|
834
|
+
});
|
|
800
835
|
await store.createLink("01HX001", "01HX002", "derived-from", { source: "git://example" });
|
|
801
836
|
|
|
802
837
|
// Force a divergence between created_at and updated_at on n1 so
|
|
@@ -815,10 +850,20 @@ describe("portable-md round-trip — byte-equivalent re-export after blow-away i
|
|
|
815
850
|
// Blow-away import into a fresh store.
|
|
816
851
|
const restored = new SqliteStore(new Database(":memory:"));
|
|
817
852
|
const importStats = await importPortableVault(restored, { inDir: outA, blowAway: true });
|
|
818
|
-
expect(importStats.notes_created).toBe(
|
|
853
|
+
expect(importStats.notes_created).toBe(4);
|
|
819
854
|
expect(importStats.schemas_restored).toBe(1);
|
|
820
855
|
expect(importStats.links_restored).toBe(1);
|
|
821
856
|
|
|
857
|
+
// Empty-content note survives the round-trip — content is empty,
|
|
858
|
+
// path + tags persist. This is the load-bearing assertion for
|
|
859
|
+
// vault#323: prior to the EMPTY_NOTE drop, importPortableVault
|
|
860
|
+
// threw on the createNote("") call here.
|
|
861
|
+
const skeleton = await restored.getNote("01HX004");
|
|
862
|
+
expect(skeleton).not.toBeNull();
|
|
863
|
+
expect(skeleton!.content).toBe("");
|
|
864
|
+
expect(skeleton!.path).toBe("Inbox/skeleton");
|
|
865
|
+
expect(skeleton!.tags).toContain("project");
|
|
866
|
+
|
|
822
867
|
// Second export from the restored store.
|
|
823
868
|
const outB = join(tmpBase, "outB");
|
|
824
869
|
await exportVaultToDir(restored, {
|
|
@@ -999,3 +1044,511 @@ note body
|
|
|
999
1044
|
expect(existsSync(wouldBeEscape)).toBe(false);
|
|
1000
1045
|
});
|
|
1001
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
|
+
});
|