mirror of
https://github.com/onlyworlds/obsidian-plugin.git
synced 2026-07-22 11:00:31 +00:00
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:
parent
bdb1eff8d1
commit
8e24a83a93
4 changed files with 490 additions and 60 deletions
|
|
@ -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
75
Commands/export-plan.ts
Normal 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
59
test/export-plan.test.ts
Normal 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"]);
|
||||
});
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue