S9: export destination picker -- write straight into Atlas root

Add a destination choice to "Export as OnlyWorlds folder": keep the
vault-internal default (OW-folder-export/) and add "Choose a folder..."
that writes the conformant folder into any real OS directory -- point it
at the Atlas root for a one-step handoff.

- External sink uses node fs/promises + absolute paths (escapes the vault
  sandbox, verified); vault sink unchanged (vault API).
- Serialization shared via planFiles(); only the sink differs.
- Native picker reached by a guarded multi-path probe; falls back to a
  paste-absolute-path modal if no Electron dialog is reachable.
- Desktop-only: external option hidden on mobile.
- R3 safety: refuse if target exists or dest holds an open Atlas world
  (.atlas / lock). Pure refusal logic in export-plan.ts, unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Titus 2026-07-16 17:39:27 +02:00
parent bdb1eff8d1
commit 8e24a83a93
4 changed files with 490 additions and 60 deletions

View file

@ -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/<slug>-<id8>/` 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/<slug>-<id8>/`, 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<void> {
/**
* 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<void> {
// 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<void> {
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<void> {
/**
* 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<void> {
// 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<string>(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<void> {
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<string | null> {
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<void> {
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" });

75
Commands/export-plan.ts Normal file
View file

@ -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 `<dest>/<folderName>/` 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;

59
test/export-plan.test.ts Normal file
View file

@ -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"]);
});

View file

@ -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"
]
}