diff --git a/Commands/ExportFolderCommand.ts b/Commands/ExportFolderCommand.ts index 02188bd..5f671a5 100644 --- a/Commands/ExportFolderCommand.ts +++ b/Commands/ExportFolderCommand.ts @@ -1,4 +1,4 @@ -import { App, Modal, Notice, Setting, TFile, TFolder, normalizePath } from "obsidian"; +import { App, Modal, Notice, Platform, Setting, TFile, TFolder, normalizePath } from "obsidian"; import { v7 as uuidv7 } from "uuid"; import { readElement } from "../vault/element-file"; import { bodyFieldForCategory } from "../vault/element-transform"; @@ -10,15 +10,33 @@ import { wikilinksToOwMentions, type ElementRef, } from "../vault/folder-format"; +import { + joinExternal, + checkExternalTarget, + ATLAS_OPEN_MARKERS, + type ExportDestination, +} from "./export-plan"; /** - * Export as OnlyWorlds folder (S9 Phase C / Rail 2). + * Export as OnlyWorlds folder (S9 Phase C / Rail 2, + S9 destination picker). * * Walks a world's element notes and writes a conformant OnlyWorlds folder — the - * Track H standard Atlas reads directly. VAULT-INTERNAL only (R2): the folder is - * written under `OW-folder-export/-/` inside the vault. The user moves - * it into their Atlas root themselves (the completion notice says so). No node - * fs, no paths outside the vault — mobile-safe, permission-clean. + * Track H standard Atlas reads directly. The user chooses WHERE it lands (R1): + * - (default) IN THE VAULT — `OW-folder-export/-/`, vault API. The + * zero-friction, mobile-safe default; the user moves it into Atlas by hand. + * - CHOOSE A FOLDER… — a real OS directory (e.g. their Atlas root), written + * with node fs/promises + absolute paths (R2) so the bytes actually escape + * the vault sandbox. Desktop-only; hidden on mobile (no Atlas there). + * + * The serialization (world.json + element bodies + ow:// prose) is identical for + * both destinations — only the SINK differs (vault.create vs fs.writeFile). See + * planFiles(): it produces the in-memory file list once; a sink writes it. + * + * THE ELECTRON UNKNOWN (disclosed): the native directory picker is reached by + * probing several known renderer paths at runtime (pickDirectoryNative). Obsidian + * dropped Electron's `remote` module years ago, and `@electron/remote` is not + * bundled into plugins, so no single path is guaranteed. If none resolves we FALL + * BACK to a paste-absolute-path modal — an acceptable ship (brief R1 / gate note). * * Conformance (R1, from the gold-standard writer azgaar-converter/src/folder.js): * - world.json carries id + name (+ readable World.md meta); @@ -30,10 +48,9 @@ import { * [Label](ow://type/uuid) where the name resolves. Extension fields round-trip * verbatim (R4) — carried by readElement/the transform, never touched here. * - * World identity (disclosed judgment call): World.md carries no world UUID (only - * api_key + readable meta — see Scripts/WorldDataTemplate.ts). So world.json.id - * is minted here as a fresh UUIDv7 — exactly what folder.js does for its own - * world.json. Element ids are the real note ids and are preserved. + * World identity: World.md carries no world UUID, so world.json.id is the + * persisted per-vault world id (readWorldIdMarker), minted once if absent. A + * stable id keeps re-exports the SAME world in Atlas. */ export class ExportFolderCommand { private app: App; @@ -48,7 +65,9 @@ export class ExportFolderCommand { new Notice("No OnlyWorlds worlds found in this vault."); return; } - new WorldPickModal(this.app, worlds, (world) => void this.exportWorld(world)).open(); + new WorldPickModal(this.app, worlds, (world) => { + new DestinationPickModal(this.app, (dest) => void this.exportWorld(world, dest)).open(); + }).open(); } private getWorldFolders(): string[] { @@ -85,11 +104,22 @@ export class ExportFolderCommand { return meta; } - private async exportWorld(world: string): Promise { + /** + * Build the complete in-memory file list for a world export: world.json plus + * every element body, each as { rel, content } with the same bytes regardless + * of sink. Returns null (with a Notice) when there is nothing to write. + */ + private async planFiles( + world: string, + worldId: string + ): Promise<{ + files: { rel: string; content: string }[]; + failed: { path: string; reason: string }[]; + } | null> { const files = this.elementFilesFor(world); if (files.length === 0) { new Notice(`No element notes found in ${world}.`); - return; + return null; } // Pass 1 — read every element (frontmatter, span-tolerant) and build a @@ -113,21 +143,62 @@ export class ExportFolderCommand { } if (elements.length === 0) { new Notice(`Export aborted — no readable elements in ${world}.`); - return; + return null; } - // Pass 2 — translate prose, stamp folder bodies, plan paths, write. + // Pass 2 — translate prose, stamp folder bodies, plan relative paths. const stamp = new Date().toISOString(); - // STABLE world identity (keeper ruling, 2026-07-16): read the persisted - // per-vault world id, mint only if none exists yet, persist what we mint. - // A fresh id per export would make every re-export a NEW world in Atlas - // and trip the import guard against this world's own earlier import. + const worldMeta = await this.readWorldMeta(world); + const planned: { rel: string; content: string }[] = [ + { + rel: "world.json", + content: JSON.stringify({ id: worldId, name: world, ...worldMeta }, null, 2) + "\n", + }, + ]; + for (const el of elements) { + const bodyField = bodyFieldForCategory(el.type); + const payload = { ...el.payload }; + if (typeof payload[bodyField] === "string") { + payload[bodyField] = wikilinksToOwMentions( + payload[bodyField] as string, + (n) => nameIndex.get(n) ?? null + ); + } + const body = buildFolderElementBody(el.type, payload, stamp); + const rel = elementRelPath(el.type, { id: String(el.payload.id), name: el.payload.name }); + planned.push({ rel, content: JSON.stringify(body, null, 2) + "\n" }); + } + return { files: planned, failed }; + } + + private async exportWorld(world: string, dest: ExportDestination): Promise { + // STABLE world identity: read the persisted per-vault world id, mint only + // if none exists yet. A fresh id per export would make every re-export a + // NEW world in Atlas and trip the import guard against its own earlier + // import. let worldId = await readWorldIdMarker(this.app, world); if (!worldId) { worldId = uuidv7(); await writeWorldIdMarker(this.app, world, worldId); } const folderName = worldFolderName(world, worldId); + + const plan = await this.planFiles(world, worldId); + if (!plan) return; + + if (dest.kind === "external") { + await this.writeExternal(world, folderName, dest.dir, plan); + } else { + await this.writeVault(world, folderName, plan); + } + } + + /** Vault sink (default): unchanged behavior — vault API under OW-folder-export/. */ + private async writeVault( + world: string, + folderName: string, + plan: { files: { rel: string; content: string }[]; failed: { path: string; reason: string }[] } + ): Promise { const root = normalizePath(`OW-folder-export/${folderName}`); if (this.app.vault.getAbstractFileByPath(root)) { new Notice( @@ -136,44 +207,83 @@ export class ExportFolderCommand { ); return; } - - const written: string[] = []; try { - // world.json (R1a). - const worldMeta = await this.readWorldMeta(world); - await this.ensureFolder(root); - await this.app.vault.create( - normalizePath(`${root}/world.json`), - JSON.stringify({ id: worldId, name: world, ...worldMeta }, null, 2) + "\n" - ); - written.push("world.json"); - - for (const el of elements) { - const bodyField = bodyFieldForCategory(el.type); - const payload = { ...el.payload }; - // Translate body prose wikilinks -> ow:// where they resolve (R3). - if (typeof payload[bodyField] === "string") { - payload[bodyField] = wikilinksToOwMentions( - payload[bodyField] as string, - (n) => nameIndex.get(n) ?? null - ); - } - const body = buildFolderElementBody(el.type, payload, stamp); - const rel = elementRelPath(el.type, { id: String(el.payload.id), name: el.payload.name }); - const full = normalizePath(`${root}/${rel}`); - await this.ensureFolder(full.substring(0, full.lastIndexOf("/"))); - await this.app.vault.create(full, JSON.stringify(body, null, 2) + "\n"); - written.push(rel); + await this.ensureVaultFolder(root); + for (const f of plan.files) { + const full = normalizePath(`${root}/${f.rel}`); + await this.ensureVaultFolder(full.substring(0, full.lastIndexOf("/"))); + await this.app.vault.create(full, f.content); } } catch (e) { new Notice(`Export failed: ${e instanceof Error ? e.message : String(e)}`, 10000); return; } - - new ExportReportModal(this.app, world, folderName, written.length, failed).open(); + new ExportReportModal(this.app, world, { + kind: "vault", + display: `OW-folder-export/${folderName}/`, + }, plan.files.length, plan.failed).open(); } - private async ensureFolder(path: string): Promise { + /** + * External sink (R2): node fs/promises + ABSOLUTE paths so the write escapes + * the vault sandbox. R3 preflight: refuse if the target already exists or the + * chosen dir holds an open Atlas world (.atlas / lock). + */ + private async writeExternal( + world: string, + folderName: string, + destDir: string, + plan: { files: { rel: string; content: string }[]; failed: { path: string; reason: string }[] } + ): Promise { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const fs = require("fs/promises") as typeof import("fs/promises"); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const path = require("path") as typeof import("path"); + + const target = joinExternal(destDir, folderName, path.sep); + + // R3 preflight — cheap probes, then pure refusal logic. + let targetExists = false; + try { + await fs.stat(target); + targetExists = true; + } catch { + /* absent — good */ + } + let destHasAtlasLock = false; + try { + const entries = await fs.readdir(destDir); + const markers = new Set(ATLAS_OPEN_MARKERS); + destHasAtlasLock = entries.some((e) => markers.has(e)); + } catch { + /* dir unreadable/absent — checkExternalTarget will not flag atlas-open */ + } + const verdict = checkExternalTarget(folderName, targetExists, destHasAtlasLock); + if (!verdict.ok) { + new Notice(verdict.message, 10000); + return; + } + + try { + for (const f of plan.files) { + const full = path.join(target, ...f.rel.split("/")); + await fs.mkdir(path.dirname(full), { recursive: true }); + await fs.writeFile(full, f.content, "utf8"); + } + } catch (e) { + new Notice( + `Export failed writing to ${target}: ${e instanceof Error ? e.message : String(e)}`, + 10000 + ); + return; + } + new ExportReportModal(this.app, world, { + kind: "external", + display: target, + }, plan.files.length, plan.failed).open(); + } + + private async ensureVaultFolder(path: string): Promise { const norm = normalizePath(path); if (!norm || this.app.vault.getAbstractFileByPath(norm)) return; const parts = norm.split("/"); @@ -187,6 +297,75 @@ export class ExportFolderCommand { } } +/** + * Probe for a reachable native directory picker and, if found, open it. + * Returns the chosen absolute dir, or null if the user cancelled OR no native + * dialog API is reachable in this Obsidian/Electron build. + * + * We try, in order, the renderer-reachable paths that have appeared across + * Obsidian/Electron versions. Each is fully guarded — a throw or undefined at any + * step just moves to the next, and exhausting them returns null so the caller can + * fall back to the paste-path modal. `showOpenDialog` (async, main-process + * marshalled) is preferred; `showOpenDialogSync` is the older renderer form. + */ +async function pickDirectoryNative(): Promise { + type DialogLike = { + showOpenDialog?: (opts: unknown) => Promise<{ canceled: boolean; filePaths: string[] }>; + showOpenDialogSync?: (opts: unknown) => string[] | undefined; + }; + const opts = { + title: "Choose export destination (e.g. your Atlas root)", + properties: ["openDirectory", "createDirectory"], + }; + + const candidates: (() => DialogLike | undefined)[] = [ + // Modern main-process dialog via @electron/remote, IF a host bundles it. + () => tryRequire("@electron/remote")?.dialog as DialogLike | undefined, + // Legacy renderer `remote` (gone in current Electron, kept for old hosts). + () => tryRequire("electron")?.remote?.dialog as DialogLike | undefined, + // Some Obsidian builds expose electron on window with a remote bridge. + () => (window as unknown as { electron?: { remote?: { dialog?: DialogLike } } }) + .electron?.remote?.dialog, + // Direct renderer dialog (present in a few Electron configurations). + () => tryRequire("electron")?.dialog as DialogLike | undefined, + ]; + + for (const get of candidates) { + let dialog: DialogLike | undefined; + try { + dialog = get(); + } catch { + continue; + } + if (!dialog) continue; + try { + if (typeof dialog.showOpenDialog === "function") { + const res = await dialog.showOpenDialog(opts); + if (res.canceled || res.filePaths.length === 0) return null; + return res.filePaths[0]; + } + if (typeof dialog.showOpenDialogSync === "function") { + const paths = dialog.showOpenDialogSync(opts); + return paths && paths.length > 0 ? paths[0] : null; + } + } catch { + // This dialog surfaced but failed to open — try the next candidate. + continue; + } + } + return null; +} + +/** require() that never throws — returns undefined if the module isn't present. */ +function tryRequire(id: string): any { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require(id); + } catch { + return undefined; + } +} + /** World picker (export copy). */ class WorldPickModal extends Modal { constructor(app: App, private worlds: string[], private onPick: (world: string) => void) { @@ -198,10 +377,7 @@ class WorldPickModal extends Modal { contentEl.empty(); contentEl.createEl("h2", { text: "Export as OnlyWorlds folder" }); const p = contentEl.createEl("p", { - text: - "Writes this world as a conformant OnlyWorlds folder under " + - "OW-folder-export/ in this vault. Move that folder into your Atlas root " + - "to open it in Atlas.", + text: "Pick a world to export as a conformant OnlyWorlds folder.", }); p.style.fontSize = "0.9em"; for (const world of this.worlds) { @@ -222,12 +398,122 @@ class WorldPickModal extends Modal { } } +/** + * Destination picker (R1): choose IN THE VAULT (default) or an external folder. + * The external branch is desktop-only — hidden on mobile (Platform.isMobile), + * which has no Atlas and no reachable OS picker. Choosing external opens the + * native picker, falling back to a paste-path modal if none is reachable. + */ +class DestinationPickModal extends Modal { + constructor(app: App, private onPick: (dest: ExportDestination) => void) { + super(app); + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl("h2", { text: "Where should the folder go?" }); + + new Setting(contentEl) + .setName("In the vault (default)") + .setDesc("Writes under OW-folder-export/ in this vault. Move it into your Atlas root after.") + .addButton((b) => + b + .setButtonText("Use vault") + .setCta() + .onClick(() => { + this.close(); + this.onPick({ kind: "vault" }); + }) + ); + + if (!Platform.isMobile) { + new Setting(contentEl) + .setName("Choose a folder…") + .setDesc( + "Write straight into a folder you pick — point it at your Atlas root " + + "(…/onlyworlds-atlas/) for a one-step handoff." + ) + .addButton((b) => + b.setButtonText("Choose folder").onClick(() => { + this.close(); + void this.chooseExternal(); + }) + ); + } + } + + private async chooseExternal(): Promise { + const dir = await pickDirectoryNative(); + if (dir) { + this.onPick({ kind: "external", dir }); + return; + } + // No native picker reachable (or cancelled) — offer the paste-path modal. + new PastePathModal(this.app, (typed) => this.onPick({ kind: "external", dir: typed })).open(); + } + + onClose() { + this.contentEl.empty(); + } +} + +/** + * Fallback when no native directory picker is reachable: the user pastes an + * absolute path. Empty input cancels. No path validation here — the external + * sink's fs write surfaces any bad path as a clear failure Notice. + */ +class PastePathModal extends Modal { + private value = ""; + constructor(app: App, private onSubmit: (dir: string) => void) { + super(app); + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl("h2", { text: "Paste destination folder" }); + const p = contentEl.createEl("p", { + text: + "No system folder picker was available. Paste the ABSOLUTE path of the " + + "destination folder (e.g. your Atlas root).", + }); + p.style.fontSize = "0.9em"; + + new Setting(contentEl).setName("Absolute path").addText((t) => { + t.setPlaceholder("C:\\Users\\you\\Desktop\\TESTOWFOLDER\\onlyworlds-atlas"); + t.onChange((v) => (this.value = v.trim())); + t.inputEl.style.width = "100%"; + }); + + new Setting(contentEl) + .addButton((b) => + b + .setButtonText("Write here") + .setCta() + .onClick(() => { + if (!this.value) { + new Notice("Enter an absolute path, or cancel."); + return; + } + this.close(); + this.onSubmit(this.value); + }) + ) + .addButton((b) => b.setButtonText("Cancel").onClick(() => this.close())); + } + + onClose() { + this.contentEl.empty(); + } +} + /** End-of-run report. */ class ExportReportModal extends Modal { constructor( app: App, private world: string, - private folderName: string, + private dest: { kind: "vault" | "external"; display: string }, private writtenCount: number, private failed: { path: string; reason: string }[] ) { @@ -245,14 +531,19 @@ class ExportReportModal extends Modal { const where = contentEl.createEl("p"); where.createEl("strong", { text: "Folder: " }); - where.appendText(`OW-folder-export/${this.folderName}/`); + where.appendText(this.dest.display); const next = contentEl.createEl("p"); next.style.fontSize = "0.9em"; - next.appendText( - "To open in Atlas: move this folder out of the vault and into your Atlas root. " + - "(The folder is self-contained — world.json + elements/ + spatial/.)" - ); + if (this.dest.kind === "external") { + next.appendText("Open Atlas to see it — if you wrote into your Atlas root, it appears on next open."); + } else { + next.appendText( + "To open in Atlas: move this folder into your Atlas root, or re-run export " + + "and choose that folder directly. (The folder is self-contained — " + + "world.json + elements/ + spatial/.)" + ); + } if (this.failed.length > 0) { contentEl.createEl("h3", { text: "Skipped notes" }); diff --git a/Commands/export-plan.ts b/Commands/export-plan.ts new file mode 100644 index 0000000..f1b1f7e --- /dev/null +++ b/Commands/export-plan.ts @@ -0,0 +1,75 @@ +/** + * Export destination planning — PURE logic (S9 export picker). + * + * No Obsidian, node, or Electron imports: this module is unit-tested under plain + * `node --test` (like vault/folder-format.ts). The command file owns the actual + * I/O — the native directory picker probe, the fs/promises external sink, and the + * vault-API internal sink. This module only decides paths and refusal reasons so + * that the escape-the-vault question and the R3 safety refusals are testable + * without a running Obsidian. + */ + +/** Where an export lands. `vault` = current behavior; `external` = a real OS dir. */ +export type ExportDestination = + | { kind: "vault" } + | { kind: "external"; dir: string }; + +/** + * Join an absolute external directory with a world folder name, POSIX or Windows. + * + * The dialog hands back a native absolute path (`C:\Users\…\onlyworlds-atlas` on + * Windows, `/home/…/onlyworlds-atlas` on POSIX). We must NOT route this through + * Obsidian's normalizePath — that lowercases nothing but strips the drive-letter + * colon handling we need and is meant for vault-relative paths. Instead join with + * the separator already present in the parent, defaulting to the platform's. + */ +export function joinExternal(dir: string, folderName: string, sep: string): string { + const trimmed = dir.replace(/[\\/]+$/, ""); + return `${trimmed}${sep}${folderName}`; +} + +/** Reasons an external target is refused before any bytes are written (R3). */ +export type ExternalRefusal = + | { ok: true } + | { ok: false; reason: "exists"; message: string } + | { ok: false; reason: "atlas-open"; message: string }; + +/** + * Decide whether an external target folder is safe to write, given cheap probe + * results the caller gathered with fs (R3): + * - `targetExists`: does `//` already exist? + * - `destHasAtlasLock`: does the CHOSEN destination dir contain a top-level + * `.atlas` dir or a `lock` file? (an Atlas world held open — the race class) + * + * Refuses on either. Pure so the branch logic is test-covered; the caller does + * the fs.stat / readdir and passes the booleans in. + */ +export function checkExternalTarget( + folderName: string, + targetExists: boolean, + destHasAtlasLock: boolean +): ExternalRefusal { + if (destHasAtlasLock) { + return { + ok: false, + reason: "atlas-open", + message: + "That folder looks like an Atlas world that's currently open " + + "(it contains a .atlas or lock file). Close Atlas or pick its parent " + + "root instead, then re-run the export.", + }; + } + if (targetExists) { + return { + ok: false, + reason: "exists", + message: + `A folder named ${folderName} already exists there. Move or delete it ` + + "first — export never overwrites an existing folder.", + }; + } + return { ok: true }; +} + +/** The dotfile/lockfile names that mark a folder as an Atlas world held open. */ +export const ATLAS_OPEN_MARKERS = [".atlas", "lock"] as const; diff --git a/test/export-plan.test.ts b/test/export-plan.test.ts new file mode 100644 index 0000000..51e0271 --- /dev/null +++ b/test/export-plan.test.ts @@ -0,0 +1,59 @@ +/** + * S9 export picker — pure destination-planning logic. + * + * Covers the escape-the-vault path join (both separators) and the R3 external + * refusal branches. The I/O (native picker probe, fs sink, vault sink) lives in + * ExportFolderCommand.ts and is exercised in a running Obsidian, not here. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + joinExternal, + checkExternalTarget, + ATLAS_OPEN_MARKERS, +} from "../Commands/export-plan"; + +test("joinExternal: Windows sep, strips trailing separators", () => { + assert.equal( + joinExternal("C:\\Users\\me\\onlyworlds-atlas", "aria-018f2a3b", "\\"), + "C:\\Users\\me\\onlyworlds-atlas\\aria-018f2a3b" + ); + assert.equal( + joinExternal("C:\\Users\\me\\onlyworlds-atlas\\", "aria-018f2a3b", "\\"), + "C:\\Users\\me\\onlyworlds-atlas\\aria-018f2a3b" + ); +}); + +test("joinExternal: POSIX sep, strips trailing slash", () => { + assert.equal( + joinExternal("/home/me/onlyworlds-atlas/", "aria-018f2a3b", "/"), + "/home/me/onlyworlds-atlas/aria-018f2a3b" + ); +}); + +test("checkExternalTarget: clean target is allowed", () => { + const v = checkExternalTarget("aria-018f2a3b", false, false); + assert.equal(v.ok, true); +}); + +test("checkExternalTarget: existing target is refused (never overwrite)", () => { + const v = checkExternalTarget("aria-018f2a3b", true, false); + assert.equal(v.ok, false); + if (!v.ok) assert.equal(v.reason, "exists"); +}); + +test("checkExternalTarget: open-Atlas dest is refused (race class)", () => { + const v = checkExternalTarget("aria-018f2a3b", false, true); + assert.equal(v.ok, false); + if (!v.ok) assert.equal(v.reason, "atlas-open"); +}); + +test("checkExternalTarget: atlas-open takes precedence over exists", () => { + const v = checkExternalTarget("aria-018f2a3b", true, true); + assert.equal(v.ok, false); + if (!v.ok) assert.equal(v.reason, "atlas-open"); +}); + +test("ATLAS_OPEN_MARKERS are the documented dotfile/lockfile names", () => { + assert.deepEqual([...ATLAS_OPEN_MARKERS], [".atlas", "lock"]); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index 67768d1..e581405 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -12,5 +12,10 @@ "esModuleInterop": true, "rootDir": "." }, - "include": ["vault/element-transform.ts", "vault/folder-format.ts", "test/**/*.ts"] + "include": [ + "vault/element-transform.ts", + "vault/folder-format.ts", + "Commands/export-plan.ts", + "test/**/*.ts" + ] }