diff --git a/docs/releases/1.0.0/readiness.md b/docs/releases/1.0.0/readiness.md index 2055873..c7591c4 100644 --- a/docs/releases/1.0.0/readiness.md +++ b/docs/releases/1.0.0/readiness.md @@ -7,7 +7,7 @@ Runtime: Node v24.16.0, npm 11.13.0, macOS 27.0 (26A428, arm64); Obsidian versio | Gate | Status | Source commit / artifact SHA-256 | Evidence | Remaining action | |---|---|---|---|---| | Baseline | PASS | 2915687ebd656444054387fa474e0393bf420d7b | 2026-09-15 local run: `npm run check:version` ("Version metadata is consistent: 0.7.4"), `npm run lint:obsidian-warnings`, `npm run build`, `npm test` (20 files, 360 tests passed) — all exit 0. Remote read-only: 0 open issues, 0 open PRs; release 0.7.4 (published 2026-08-26) with `main.js`, `manifest.json`, `styles.css`; latest `verify` runs green including HEAD 2915687. No vault plugin symlink present, so builds cannot update a live plugin. | T0 complete | -| Output integrity | NOT RUN | Unmeasured | No run recorded | Execute T1-T2 | +| Output integrity | PASS | this branch's `fix: preserve existing export documents and assets` commit | T1 reproduced the overwrite corruption as required (expected `[1]`, received `[2]` in `src/export/ExportIntegrity.test.ts`). T2 added directory isolation, exclusive writes (`wx` external / create-only vault), report-name protection and a 10-case regression matrix. 2026-09-16: five export suites 145/145, full suite 378/378, lint and build exit 0. | T6 reruns the two-run case through the native export dialog | | Outcomes | NOT RUN | Unmeasured | No run recorded | Execute T3-T4 | | Headless artifacts | NOT RUN | Unmeasured | No run recorded | Execute T5 | | Native artifacts | NOT RUN | Unmeasured | No run recorded | Execute T6 | diff --git a/docs/superpowers/plans/2026-09-12-1.0.0-release-readiness.md b/docs/superpowers/plans/2026-09-12-1.0.0-release-readiness.md index 1d2ad96..1dcaef2 100644 --- a/docs/superpowers/plans/2026-09-12-1.0.0-release-readiness.md +++ b/docs/superpowers/plans/2026-09-12-1.0.0-release-readiness.md @@ -47,8 +47,8 @@ Alternatives intentionally rejected: pre-scanning every renderer to predict exac ## Checklist and dependency order - [x] T0 — Refresh baseline and create evidence record. -- [ ] T1 — Add persistent in-memory vault fixture and reproduce output corruption. -- [ ] T2 — Implement directory isolation, exclusive writes and report-name protection. +- [x] T1 — Add persistent in-memory vault fixture and reproduce output corruption. +- [x] T2 — Implement directory isolation, exclusive writes and report-name protection. - [ ] T3 — Define structured outcomes and preserve partial results. - [ ] T4 — Present accurate completion/cancellation/failure messages. - [ ] T5 — Add reproducible artifact fixtures and automated contract checks. diff --git a/eslint.config.mts b/eslint.config.mts index 49b50e7..1692af8 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -29,6 +29,8 @@ export default tseslint.config( // Tests hand-polyfill Obsidian DOM globals in jsdom; the rule's // activeWindow.createDiv() suggestion does not type-check there. "obsidianmd/prefer-create-el": "off", + // Tests run under vitest in Node and are never bundled for mobile. + "obsidianmd/no-nodejs-modules": "off", }, }, globalIgnores([ diff --git a/src/export/ExportIntegrity.test.ts b/src/export/ExportIntegrity.test.ts new file mode 100644 index 0000000..02948b5 --- /dev/null +++ b/src/export/ExportIntegrity.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { ExportRunner } from "@/export/ExportRunner"; +import { ExportPlanBuilder } from "@/export/ExportPlan"; +import { OutputWriter } from "@/export/OutputWriter"; +import { DEFAULT_SETTINGS, ExportSettings } from "@/types"; +import { createMemoryVault } from "@/test-support/memory-vault"; + +// Real runner, collector, rewriter and writer with a persistent in-memory +// vault: writes survive across runs so cross-run corruption is observable. +// Only timestampSuffix is stubbed (for deterministic relocation paths); no +// write method is ever mocked here. +const TIMESTAMP = "2026-09-16T00-00-00"; + +function integritySettings(overrides: Partial = {}): ExportSettings { + return { + ...DEFAULT_SETTINGS, + expandEmbeds: false, + copyAttachments: true, + overwriteExisting: false, + ...overrides, + }; +} + +function singleFilePlan( + fixture: ReturnType, + path: string, + name: string, +) { + return new ExportPlanBuilder( + fixture.app, { type: "current-file", path }, + "markdown-bundle", "exports", name, + ).setInputFiles([path]).build(); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("export integrity", () => { + it("preserves earlier attachments across sequential single-note exports", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "![[a/img.png]]"); + fixture.putText("b/B.md", "![[b/img.png]]"); + fixture.putBinary("a/img.png", new Uint8Array([1])); + fixture.putBinary("b/img.png", new Uint8Array([2])); + const settings = integritySettings(); + const run = (path: string, name: string) => + new ExportRunner(fixture.app).run(singleFilePlan(fixture, path, name), settings); + const first = await run("a/A.md", "A"); + const original = fixture.text("exports/A.md"); + const second = await run("b/B.md", "B"); + expect(first.success).toBe(true); + expect(second.success).toBe(true); + expect(fixture.text("exports/A.md")).toBe(original); + expect(Array.from(fixture.bytes("exports/assets/img.png"))).toEqual([1]); + expect(second.outputRoot).not.toBe(first.outputRoot); + expect(Array.from(fixture.bytes(`${second.outputRoot}/assets/img.png`))).toEqual([2]); + expect(fixture.text(`${second.outputRoot}/B.md`)).toContain("assets/img.png"); + }); + + it("relocates when the single-file output root is an existing empty directory", async () => { + vi.spyOn(OutputWriter.prototype, "timestampSuffix").mockReturnValue(TIMESTAMP); + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "content"); + await fixture.app.vault.createFolder("exports"); + + const result = await new ExportRunner(fixture.app) + .run(singleFilePlan(fixture, "a/A.md", "A"), integritySettings()); + + expect(result.success).toBe(true); + expect(result.outputRoot).toBe(`exports-${TIMESTAMP}`); + expect(fixture.text(`exports-${TIMESTAMP}/A.md`)).toContain("A"); + expect(fixture.paths()).not.toContain("exports/A.md"); + }); + + it("relocates past occupied root and existing timestamp/suffix candidates without modifying them", async () => { + vi.spyOn(OutputWriter.prototype, "timestampSuffix").mockReturnValue(TIMESTAMP); + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "content"); + fixture.putText("exports", "occupied file at root"); + fixture.putText(`exports-${TIMESTAMP}`, "taken"); + fixture.putText(`exports-${TIMESTAMP}-2`, "taken"); + + const result = await new ExportRunner(fixture.app) + .run(singleFilePlan(fixture, "a/A.md", "A"), integritySettings()); + + expect(result.success).toBe(true); + expect(result.outputRoot).toBe(`exports-${TIMESTAMP}-3`); + expect(fixture.text(`exports-${TIMESTAMP}-3/A.md`)).toContain("A"); + expect(fixture.text("exports")).toBe("occupied file at root"); + expect(fixture.text(`exports-${TIMESTAMP}`)).toBe("taken"); + expect(fixture.text(`exports-${TIMESTAMP}-2`)).toBe("taken"); + }); + + it("keeps primaries, links, assets and report on the relocated batch leaf", async () => { + vi.spyOn(OutputWriter.prototype, "timestampSuffix").mockReturnValue(TIMESTAMP); + const fixture = createMemoryVault(); + fixture.putText("notes/A.md", "![[notes/img.png]] See [[B]] and [[missing]]"); + fixture.putText("notes/B.md", "B content"); + fixture.putBinary("notes/img.png", new Uint8Array([7])); + await fixture.app.vault.createFolder("exports/notes"); + + const plan = new ExportPlanBuilder( + fixture.app, + { type: "folder", path: "notes", recursive: true }, + "markdown-bundle", "exports", "index", "notes", + ).setInputFiles(["notes/A.md", "notes/B.md"]).build(); + const result = await new ExportRunner(fixture.app).run(plan, integritySettings()); + + const leaf = `exports/notes-${TIMESTAMP}`; + expect(result.success).toBe(true); + expect(fixture.text(`${leaf}/A.md`)).toContain("[B](B.md)"); + expect(fixture.text(`${leaf}/A.md`)).toContain("assets/img.png"); + expect(fixture.text(`${leaf}/B.md`)).toContain("B content"); + expect(Array.from(fixture.bytes(`${leaf}/assets/img.png`))).toEqual([7]); + expect(fixture.text(`${leaf}/export-report.md`)).toContain("Unresolved link: missing"); + expect(fixture.paths()).not.toContain("exports/notes/A.md"); + }); + + it("keeps a primary named export-report.md and moves the report to a fresh name", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "Link to [[missing]]"); + + const result = await new ExportRunner(fixture.app) + .run(singleFilePlan(fixture, "a/A.md", "export-report"), integritySettings()); + + expect(result.success).toBe(true); + const primary = fixture.text("exports/export-report.md"); + expect(primary).toContain("# A"); + expect(primary).not.toContain("Unresolved link"); + expect(fixture.text("exports/export-report-2.md")).toContain("Unresolved link: missing"); + }); + + it("skips an existing export-report-2.md when allocating the report", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "Link to [[missing]]"); + fixture.putText("exports/export-report-2.md", "previous report"); + + const result = await new ExportRunner(fixture.app) + .run( + singleFilePlan(fixture, "a/A.md", "export-report"), + integritySettings({ overwriteExisting: true }), + ); + + expect(result.success).toBe(true); + expect(fixture.text("exports/export-report.md")).toContain("# A"); + expect(fixture.text("exports/export-report-2.md")).toBe("previous report"); + expect(fixture.text("exports/export-report-3.md")).toContain("Unresolved link: missing"); + }); + + it("never overwrites a prior report even with overwrite enabled", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "Link to [[missing]]"); + fixture.putText("exports/export-report.md", "OLD REPORT"); + + const result = await new ExportRunner(fixture.app) + .run( + singleFilePlan(fixture, "a/A.md", "A"), + integritySettings({ overwriteExisting: true }), + ); + + expect(result.success).toBe(true); + expect(fixture.text("exports/export-report.md")).toBe("OLD REPORT"); + expect(fixture.text("exports/export-report-2.md")).toContain("Unresolved link: missing"); + }); + + it("refuses to modify a destination that appears after plan resolution", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "content"); + + const result = await new ExportRunner(fixture.app).run( + singleFilePlan(fixture, "a/A.md", "A"), + integritySettings(), + { + onFileStart: () => {}, + onFileComplete: () => {}, + onPhase: (phase) => { + if (phase === "Assembling document") { + void fixture.app.vault.create("exports/A.md", "PRE-EXISTING"); + } + }, + }, + ); + + expect(result.success).toBe(false); + expect(result.warnings[0]).toContain("Output already exists: exports/A.md"); + expect(fixture.text("exports/A.md")).toBe("PRE-EXISTING"); + }); + + it("still overwrites primary and attachment output when overwrite is enabled", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "![[a/img.png]]"); + fixture.putBinary("a/img.png", new Uint8Array([1])); + const settings = integritySettings({ overwriteExisting: true }); + const runner = new ExportRunner(fixture.app); + + await runner.run(singleFilePlan(fixture, "a/A.md", "A"), settings); + expect(Array.from(fixture.bytes("exports/assets/img.png"))).toEqual([1]); + + fixture.remove("a/img.png"); + fixture.putBinary("a/img.png", new Uint8Array([9])); + const second = await runner.run(singleFilePlan(fixture, "a/A.md", "A"), settings); + + expect(second.success).toBe(true); + expect(second.outputRoot).toBe("exports"); + expect(Array.from(fixture.bytes("exports/assets/img.png"))).toEqual([9]); + }); + + it("surfaces a missing attachment as a failed copy warning", async () => { + const fixture = createMemoryVault(); + fixture.putText("a/A.md", "![[a/img.png]]"); + fixture.putBinary("a/img.png", new Uint8Array([1])); + + const result = await new ExportRunner(fixture.app).run( + singleFilePlan(fixture, "a/A.md", "A"), + integritySettings(), + { + onFileStart: () => {}, + onFileComplete: () => {}, + onPhase: (phase) => { + if (phase === "Copying attachments") { + fixture.remove("a/img.png"); + } + }, + }, + ); + + expect(result.success).toBe(true); + expect(result.warnings).toContain("Failed to copy attachment: a/img.png"); + expect(fixture.text("exports/export-report.md")).toContain("Failed to copy attachment"); + }); +}); diff --git a/src/export/ExportRunner.test.ts b/src/export/ExportRunner.test.ts index a893797..4ef7187 100644 --- a/src/export/ExportRunner.test.ts +++ b/src/export/ExportRunner.test.ts @@ -339,22 +339,27 @@ describe("ExportRunner", () => { }); describe("output collisions", () => { - it("keeps the original root when the directory exists but the target file does not", async () => { + it("relocates when the single-file output root exists even if the target file does not", async () => { const app = createPathAwareMockApp(["note.md"], ["exports"]); const runner = new ExportRunner(app as never); + vi.spyOn(OutputWriter.prototype, "timestampSuffix").mockReturnValue("2026-07-29"); const writeSpy = vi.spyOn(OutputWriter.prototype, "writeText") .mockResolvedValue(undefined); const result = await runner.run(makePlan(["note.md"]), defaultSettings()); - expect(result.outputRoot).toBe("exports"); + expect(result.outputRoot).toBe("exports-2026-07-29"); expect(writeSpy).toHaveBeenCalledWith( + "exports-2026-07-29/note.md", + expect.any(String), + ); + expect(writeSpy).not.toHaveBeenCalledWith( "exports/note.md", expect.any(String), ); }); - it("relocates the output file when the target already exists", async () => { + it("relocates when only the target file exists", async () => { const app = createPathAwareMockApp( ["note.md"], ["exports"], @@ -462,7 +467,7 @@ describe("ExportRunner", () => { outputFiles: ["/tmp/exports/note.md"], }; vi.spyOn(OutputWriter.prototype, "pathExists") - .mockImplementation((path) => path === "/tmp/exports/note.md"); + .mockImplementation((path) => path === "/tmp/exports"); vi.spyOn(OutputWriter.prototype, "timestampSuffix").mockReturnValue("2026-07-29"); vi.spyOn(OutputWriter.prototype, "ensureFolder").mockResolvedValue(undefined); const writeSpy = vi.spyOn(OutputWriter.prototype, "writeText") diff --git a/src/export/ExportRunner.ts b/src/export/ExportRunner.ts index e4472b8..5b893d7 100644 --- a/src/export/ExportRunner.ts +++ b/src/export/ExportRunner.ts @@ -52,7 +52,7 @@ export class ExportRunner { settings: ExportSettings, callbacks?: ExportProgressCallbacks, ): Promise { - const writer = new OutputWriter(this.app); + const writer = new OutputWriter(this.app, settings.overwriteExisting); const allWarnings: string[] = []; this.cancelled = false; @@ -260,16 +260,23 @@ export class ExportRunner { callbacks?.onFileComplete(i, files.length); } - // Write export report + // Write export report. Reports never overwrite anything, including + // prior reports, even when the main export allows overwrite. if (allWarnings.length > 0) { const report = allWarnings .map((w, i) => `${i + 1}. ${w}`) .join("\n"); - await writer.writeText( - `${assetsRoot}/export-report.md`, - `# Export Warnings\n\n${report}\n`, - ); + const reportWriter = new OutputWriter(this.app, false); + try { + await reportWriter.writeText( + this.reportPath(assetsRoot, effectivePlan, reportWriter), + `# Export Warnings\n\n${report}\n`, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + allWarnings.push(`Could not write export report: ${msg}`); + } } return { @@ -317,11 +324,12 @@ export class ExportRunner { if (settings.overwriteExisting) return plan; if (plan.source.type === "current-file") { - const targetPath = plan.outputFiles[0]; - if (!targetPath || !writer.pathExists(targetPath)) return plan; + // Reuse the single-file output root only if nothing occupies it; + // an existing file or directory — even an empty one — relocates the + // whole export so previous outputs keep their attachments. + if (!writer.pathExists(plan.outputRoot)) return plan; const candidateRoot = this.nextAvailablePath( - writer.timestampedFolder(plan.outputRoot), - writer, + writer.timestampedFolder(plan.outputRoot), writer, ); return relocatePlan(plan, candidateRoot); } @@ -351,4 +359,25 @@ export class ExportRunner { } return available; } + + // Pick a report path that cannot collide with any planned primary output + // or an existing file/directory. Case-insensitive reservation is + // deliberately conservative for case-insensitive filesystems. + private reportPath(root: string, plan: ExportPlan, writer: OutputWriter): string { + const reserved = new Set(plan.outputFiles.map((path) => path.toLowerCase())); + let sequence = 1; + let candidate = `${root}/export-report.md`; + const conflicts = (path: string) => { + const key = path.toLowerCase(); + return writer.pathExists(path) || [...reserved].some( + (other) => other === key || other.startsWith(`${key}/`) + || key.startsWith(`${other}/`), + ); + }; + while (conflicts(candidate)) { + sequence++; + candidate = `${root}/export-report-${sequence}.md`; + } + return candidate; + } } diff --git a/src/export/OutputWriter.test.ts b/src/export/OutputWriter.test.ts index 58ea76b..97e7c40 100644 --- a/src/export/OutputWriter.test.ts +++ b/src/export/OutputWriter.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { TFile } from "obsidian"; import { OutputWriter } from "@/export/OutputWriter"; @@ -91,6 +91,118 @@ describe("OutputWriter", () => { await writer.writeText("output/doc.md", "# Hello"); expect(app.vault.create).toHaveBeenCalledWith("output/doc.md", "# Hello"); }); + + it("modifies an existing vault file when overwrite is enabled", async () => { + const app = createMockApp({ "output/doc.md": { extension: "md" } }); + const writer = new OutputWriter(app as never); + await writer.writeText("output/doc.md", "# Updated"); + expect(app.vault.modify).toHaveBeenCalledWith( + expect.objectContaining({ path: "output/doc.md" }), + "# Updated", + ); + expect(app.vault.create).not.toHaveBeenCalled(); + }); + + it("refuses to modify an existing vault file when overwrite is disabled", async () => { + const app = createMockApp({ "output/doc.md": { extension: "md" } }); + const writer = new OutputWriter(app as never, false); + await expect(writer.writeText("output/doc.md", "# Updated")) + .rejects.toThrow("Output already exists: output/doc.md"); + expect(app.vault.modify).not.toHaveBeenCalled(); + expect(app.vault.create).not.toHaveBeenCalled(); + }); + + it("refuses to write when a folder occupies the destination", async () => { + const app = createMockApp(); + app.vault.getAbstractFileByPath = vi.fn(() => ({ path: "output", children: [] })) as never; + const writer = new OutputWriter(app as never, false); + await expect(writer.writeText("output/doc.md", "# Hello")) + .rejects.toThrow("Output already exists: output/doc.md"); + expect(app.vault.create).not.toHaveBeenCalled(); + }); + }); + + describe("writeBinary overwrite policy", () => { + it("modifies an existing vault file when overwrite is enabled", async () => { + const app = createMockApp({ "output/img.png": { extension: "png" } }); + const writer = new OutputWriter(app as never); + await writer.writeBinary("output/img.png", new Uint8Array([1])); + expect(app.vault.modifyBinary).toHaveBeenCalled(); + expect(app.vault.createBinary).not.toHaveBeenCalled(); + }); + + it("refuses to modify an existing vault file when overwrite is disabled", async () => { + const app = createMockApp({ "output/img.png": { extension: "png" } }); + const writer = new OutputWriter(app as never, false); + await expect(writer.writeBinary("output/img.png", new Uint8Array([1]))) + .rejects.toThrow("Output already exists: output/img.png"); + expect(app.vault.modifyBinary).not.toHaveBeenCalled(); + expect(app.vault.createBinary).not.toHaveBeenCalled(); + }); + }); + + describe("external write policy", () => { + // OutputWriter resolves window.require at module initialization, so each + // external test reloads the module with a stubbed window. Node builtins + // are imported dynamically to satisfy the obsidianmd lint rules. + async function freshWriter(overwrite: boolean, app = createMockApp()) { + const { createRequire } = await import("node:module"); + vi.resetModules(); + vi.stubGlobal("window", { require: createRequire(import.meta.url) }); + const { OutputWriter: Fresh } = await import("@/export/OutputWriter"); + return new Fresh(app as never, overwrite); + } + + async function tempDir(): Promise<{ dir: string; fs: typeof import("node:fs") }> { + const [fs, os, path] = await Promise.all([ + import("node:fs"), import("node:os"), import("node:path"), + ]); + return { dir: fs.mkdtempSync(path.join(os.tmpdir(), "writer-ext-")), fs }; + } + + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("refuses to modify an existing external file when overwrite is disabled", async () => { + const { dir, fs } = await tempDir(); + try { + const target = `${dir}/note.md`; + fs.writeFileSync(target, "original"); + const writer = await freshWriter(false); + await expect(writer.writeText(target, "replacement")).rejects.toThrow(); + expect(fs.readFileSync(target, "utf-8")).toBe("original"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("refuses to modify an existing external binary when overwrite is disabled", async () => { + const { dir, fs } = await tempDir(); + try { + const target = `${dir}/img.png`; + fs.writeFileSync(target, new Uint8Array([1])); + const writer = await freshWriter(false); + await expect(writer.writeBinary(target, new Uint8Array([2]))).rejects.toThrow(); + expect(Array.from(fs.readFileSync(target))).toEqual([1]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("overwrites external files when overwrite is enabled", async () => { + const { dir, fs } = await tempDir(); + try { + const target = `${dir}/note.md`; + fs.writeFileSync(target, "original"); + const writer = await freshWriter(true); + await writer.writeText(target, "replacement"); + expect(fs.readFileSync(target, "utf-8")).toBe("replacement"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("writeBinary", () => { @@ -115,10 +227,11 @@ describe("OutputWriter", () => { expect(app.vault.createBinary).toHaveBeenCalledWith("output/img.png", buf); }); - it("skips when source file not found", async () => { + it("throws when the source attachment is not found", async () => { const app = createMockApp(); const writer = new OutputWriter(app as never); - await writer.copyBinaryFile("missing.png", "output/missing.png"); + await expect(writer.copyBinaryFile("missing.png", "output/missing.png")) + .rejects.toThrow("Attachment source not found: missing.png"); expect(app.vault.readBinary).not.toHaveBeenCalled(); expect(app.vault.createBinary).not.toHaveBeenCalled(); }); diff --git a/src/export/OutputWriter.ts b/src/export/OutputWriter.ts index 9810982..c959b82 100644 --- a/src/export/OutputWriter.ts +++ b/src/export/OutputWriter.ts @@ -7,9 +7,11 @@ const nodeFs = g && "require" in g export class OutputWriter { private app: App; + private readonly overwriteExisting: boolean; - constructor(app: App) { + constructor(app: App, overwriteExisting = true) { this.app = app; + this.overwriteExisting = overwriteExisting; } static supportsExternalPaths(): boolean { @@ -37,51 +39,55 @@ export class OutputWriter { async writeText(filePath: string, content: string): Promise { if (this.isExternal(filePath)) { const fs = this.getExternalFs(); - fs.writeFileSync(filePath, content, "utf-8"); + fs.writeFileSync(filePath, content, { + encoding: "utf-8", + flag: this.overwriteExisting ? "w" : "wx", + }); return; } const existing = this.app.vault.getAbstractFileByPath(filePath); - if (existing instanceof TFile) { + if (existing) { + if (!this.overwriteExisting || !(existing instanceof TFile)) { + throw new Error(`Output already exists: ${filePath}`); + } await this.app.vault.modify(existing, content); - } else { - await this.app.vault.create(filePath, content); + return; } + await this.app.vault.create(filePath, content); } async writeBinary(filePath: string, data: ArrayBuffer | Uint8Array): Promise { if (this.isExternal(filePath)) { const fs = this.getExternalFs(); - fs.writeFileSync(filePath, data instanceof Uint8Array ? data : new Uint8Array(data)); + fs.writeFileSync( + filePath, + data instanceof Uint8Array ? data : new Uint8Array(data), + { flag: this.overwriteExisting ? "w" : "wx" }, + ); return; } const buffer = data instanceof ArrayBuffer ? data : uint8ArrayToArrayBuffer(data); const existing = this.app.vault.getAbstractFileByPath(filePath); - if (existing instanceof TFile) { + if (existing) { + if (!this.overwriteExisting || !(existing instanceof TFile)) { + throw new Error(`Output already exists: ${filePath}`); + } await this.app.vault.modifyBinary(existing, buffer); - } else { - await this.app.vault.createBinary(filePath, buffer); + return; } + await this.app.vault.createBinary(filePath, buffer); } async copyBinaryFile(sourcePath: string, destPath: string): Promise { const sourceFile = this.app.vault.getAbstractFileByPath(sourcePath); - if (!(sourceFile instanceof TFile)) return; + if (!(sourceFile instanceof TFile)) { + throw new Error(`Attachment source not found: ${sourcePath}`); + } const content = await this.app.vault.readBinary(sourceFile); - - if (this.isExternal(destPath)) { - const fs = this.getExternalFs(); - fs.writeFileSync(destPath, new Uint8Array(content)); - } else { - const existing = this.app.vault.getAbstractFileByPath(destPath); - if (existing instanceof TFile) { - await this.app.vault.modifyBinary(existing, content); - } else { - await this.app.vault.createBinary(destPath, content); - } - } + await this.writeBinary(destPath, content); } folderExists(folderPath: string): boolean { diff --git a/src/test-support/memory-vault.ts b/src/test-support/memory-vault.ts new file mode 100644 index 0000000..39b41f2 --- /dev/null +++ b/src/test-support/memory-vault.ts @@ -0,0 +1,229 @@ +import { TFile, TFolder } from "obsidian"; +import { normalizePath } from "@/export/utils"; + +export type StoredContent = string | ArrayBuffer; + +// In-memory vault that persists writes across runs so integration tests can +// detect corruption between sequential exports. Test support only — the +// metadata cache implements just the literal wiki-link syntax put into the +// fixture; heading/block resolution belongs to real Obsidian (see the 1.0.0 +// acceptance protocol). +export interface MemoryVaultFixture { + app: import("obsidian").App; + putText(path: string, text: string): TFile; + putBinary(path: string, bytes: Uint8Array): TFile; + text(path: string): string; + bytes(path: string): Uint8Array; + paths(): string[]; + remove(path: string): void; +} + +interface FolderNode extends TFolder { + children: (TFile | FolderNode)[]; +} + +const WIKI_LINK_RE = /(?(); + const contents = new Map(); + const root: FolderNode = Object.assign(new TFolder(), { + path: "", + name: "", + children: [], + }); + + function basename(path: string): string { + return path.split("/").pop() ?? path; + } + + function makeFile(path: string): TFile { + const name = basename(path); + const file = new TFile(); + file.path = path; + file.name = name; + file.basename = name.replace(/\.[^.]+$/, ""); + file.extension = name.includes(".") ? name.split(".").pop()! : ""; + return file; + } + + function makeFolder(path: string): FolderNode { + return Object.assign(new TFolder(), { path, name: basename(path), children: [] }); + } + + function parentFolder(path: string): FolderNode { + const separator = path.lastIndexOf("/"); + return separator === -1 ? root : ensureFolder(path.slice(0, separator)); + } + + function ensureFolder(path: string): FolderNode { + const normalized = normalizePath(path); + if (!normalized) return root; + const existing = nodes.get(normalized); + if (existing) { + if ("children" in existing) return existing; + throw new Error(`Folder path is occupied by a file: ${normalized}`); + } + const folder = makeFolder(normalized); + nodes.set(normalized, folder); + parentFolder(normalized).children.push(folder); + return folder; + } + + function registerFile(path: string, content: StoredContent): TFile { + const normalized = normalizePath(path); + if (!normalized) throw new Error("File path cannot be empty"); + if (nodes.has(normalized)) throw new Error(`File already exists: ${normalized}`); + const file = makeFile(normalized); + nodes.set(normalized, file); + contents.set(normalized, content); + parentFolder(normalized).children.push(file); + return file; + } + + function requireFile(path: string): TFile { + const node = nodes.get(normalizePath(path)); + if (!node || !("extension" in node)) { + throw new Error(`File not found: ${path}`); + } + return node; + } + + function cloneBuffer(data: ArrayBuffer | Uint8Array): ArrayBuffer { + const view = data instanceof Uint8Array ? data : new Uint8Array(data); + const buffer = new ArrayBuffer(view.byteLength); + new Uint8Array(buffer).set(view); + return buffer; + } + + const vault = { + getAbstractFileByPath: (path: string): TFile | TFolder | null => { + const normalized = normalizePath(path); + if (!normalized) return root; + return nodes.get(normalized) ?? null; + }, + read: async (file: TFile): Promise => { + const content = contents.get(requireFile(file.path).path); + if (typeof content !== "string") { + throw new Error(`Not a text file: ${file.path}`); + } + return content; + }, + readBinary: async (file: TFile): Promise => { + const content = contents.get(requireFile(file.path).path); + if (!(content instanceof ArrayBuffer)) { + throw new Error(`Not a binary file: ${file.path}`); + } + return cloneBuffer(content); + }, + createFolder: async (path: string): Promise => { + const normalized = normalizePath(path); + if (!normalized) throw new Error("Folder path cannot be empty"); + if (nodes.has(normalized)) throw new Error(`Folder already exists: ${normalized}`); + return ensureFolder(normalized); + }, + create: async (path: string, content: string): Promise => { + const normalized = normalizePath(path); + if (nodes.has(normalized)) throw new Error(`File already exists: ${normalized}`); + return registerFile(normalized, content); + }, + modify: async (file: TFile, content: string): Promise => { + const normalized = requireFile(file.path).path; + if (typeof contents.get(normalized) !== "string") { + throw new Error(`Not a text file: ${normalized}`); + } + contents.set(normalized, content); + }, + createBinary: async (path: string, data: ArrayBuffer): Promise => { + const normalized = normalizePath(path); + if (nodes.has(normalized)) throw new Error(`File already exists: ${normalized}`); + return registerFile(normalized, cloneBuffer(data)); + }, + modifyBinary: async (file: TFile, data: ArrayBuffer): Promise => { + const normalized = requireFile(file.path).path; + if (!(contents.get(normalized) instanceof ArrayBuffer)) { + throw new Error(`Not a binary file: ${normalized}`); + } + contents.set(normalized, cloneBuffer(data)); + }, + getMarkdownFiles: (): TFile[] => + [...nodes.values()].filter( + (node): node is TFile => "extension" in node && node.extension === "md", + ), + }; + + function parseCache(text: string): { + frontmatter: Record; + links: { link: string }[]; + embeds: { link: string }[]; + } { + const body = text.replace(/^---\r?\n(?:[\s\S]*?\r?\n)?---(?:\r?\n|$)/, ""); + const links: { link: string }[] = []; + for (const match of body.matchAll(WIKI_LINK_RE)) { + links.push({ link: match[1].split("|")[0].split("#")[0] }); + } + const embeds: { link: string }[] = []; + for (const match of body.matchAll(WIKI_EMBED_RE)) { + embeds.push({ link: match[1].split("|")[0].split("#")[0] }); + } + const frontmatter: Record = {}; + const fm = text.match(/^---\r?\n((?:[\s\S]*?\r?\n)?)---(?:\r?\n|$)/); + if (fm) { + for (const line of fm[1].split(/\r?\n/)) { + const colon = line.indexOf(":"); + if (colon > 0) frontmatter[line.slice(0, colon).trim()] = line.slice(colon + 1).trim(); + } + } + return { frontmatter, links, embeds }; + } + + const metadataCache = { + getFileCache: (file: TFile): Record | null => { + const content = contents.get(requireFile(file.path).path); + if (typeof content !== "string") return null; + return parseCache(content); + }, + getFirstLinkpathDest: (linkpath: string, sourcePath: string): TFile | null => { + const target = normalizePath(linkpath.split("#")[0].split("|")[0]); + if (!target) return null; + const separator = sourcePath.lastIndexOf("/"); + const dir = separator === -1 ? "" : sourcePath.slice(0, separator); + const candidates = dir ? [`${dir}/${target}`, target] : [target]; + for (const candidate of candidates) { + const withExtension = candidate.toLowerCase().endsWith(".md") + ? candidate + : `${candidate}.md`; + const hit = nodes.get(candidate) ?? nodes.get(withExtension); + if (hit && "extension" in hit) return hit; + } + return null; + }, + }; + + return { + app: { vault, metadataCache } as unknown as import("obsidian").App, + putText: (path, text) => registerFile(path, text), + putBinary: (path, bytes) => registerFile(path, cloneBuffer(bytes)), + text: (path) => { + const content = contents.get(requireFile(path).path); + if (typeof content !== "string") throw new Error(`Not a text file: ${path}`); + return content; + }, + bytes: (path) => { + const content = contents.get(requireFile(path).path); + if (!(content instanceof ArrayBuffer)) throw new Error(`Not a binary file: ${path}`); + return new Uint8Array(cloneBuffer(content)); + }, + paths: () => [...nodes.keys()].sort(), + remove: (path) => { + const normalized = normalizePath(path); + const node = nodes.get(normalized); + if (!node) throw new Error(`File not found: ${path}`); + nodes.delete(normalized); + contents.delete(normalized); + const parent = parentFolder(normalized); + parent.children = parent.children.filter((child) => child !== node); + }, + }; +}