mirror of
https://github.com/onlyworlds/obsidian-plugin.git
synced 2026-07-22 11:00:31 +00:00
Export minted a FRESH world id every run (faithful to folder.js, wrong for this context: azgaar conversions ARE new worlds each time; a vault world is persistent). Consequences: every re-export lands in Atlas as a brand-new world, and re-importing a re-export trips the never-merge guard against the same world's own earlier import. Fix: shared vault/world-id-marker.ts (adapter-only dotfile I/O, documented) — export reads-or-mints-and-persists the id; import records the imported folder's id as before. Re-exports are now identity-stable. Gate: tsc clean, build clean, npm test 34/34 (orchestrator hands). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
274 lines
9.5 KiB
TypeScript
274 lines
9.5 KiB
TypeScript
import { App, Modal, Notice, Setting, TFile, TFolder, normalizePath } from "obsidian";
|
|
import { writeElement } from "../vault/element-file";
|
|
import { bodyFieldForCategory, normalizeCategory } from "../vault/element-transform";
|
|
import { readWorldIdMarker, writeWorldIdMarker } from "../vault/world-id-marker";
|
|
import {
|
|
parseFolderElementBody,
|
|
owMentionsToWikilinks,
|
|
isUuid,
|
|
type ElementRef,
|
|
} from "../vault/folder-format";
|
|
|
|
/**
|
|
* Import OnlyWorlds folder (S9 Phase C / Rail 2).
|
|
*
|
|
* Reads a conformant OnlyWorlds folder the user has placed anywhere INSIDE the
|
|
* vault (R2 — folder picker over any folder containing a world.json), and mints
|
|
* frontmatter element notes through Phase B's writeElement. Deterministic inverse
|
|
* of export: same ids, same field vocabulary, ow:// -> [[wikilink]] (R3),
|
|
* extension fields verbatim (R4 — carried by writeElement/the transform).
|
|
*
|
|
* Safety (R5):
|
|
* - NEVER overwrites an existing note silently. If an element id already exists
|
|
* in the target world's notes, skip + count it.
|
|
* - Creates a NEW world folder under OnlyWorlds/Worlds/<world name> if absent.
|
|
* - If a world of that name exists but with a DIFFERENT world id, ABORT with a
|
|
* clear message (never merge two worlds). The target world's id is recorded
|
|
* in a `.ow-world-id` marker note on first import so the check is stable.
|
|
*/
|
|
export class ImportFolderCommand {
|
|
private app: App;
|
|
private markSelfWrite: (path: string) => void;
|
|
|
|
constructor(app: App, markSelfWrite: (path: string) => void = () => {}) {
|
|
this.app = app;
|
|
this.markSelfWrite = markSelfWrite;
|
|
}
|
|
|
|
async execute(): Promise<void> {
|
|
const candidates = this.findWorldFolders();
|
|
if (candidates.length === 0) {
|
|
new Notice(
|
|
"No OnlyWorlds folder found in this vault. Place a folder containing a " +
|
|
"world.json anywhere in the vault, then run this again.",
|
|
10000
|
|
);
|
|
return;
|
|
}
|
|
new FolderPickModal(this.app, candidates, (dir) => void this.importFolder(dir)).open();
|
|
}
|
|
|
|
/** Any vault folder directly containing a world.json is an import candidate. */
|
|
private findWorldFolders(): string[] {
|
|
const dirs = new Set<string>();
|
|
for (const f of this.app.vault.getFiles()) {
|
|
if (f.name.toLowerCase() === "world.json") {
|
|
const dir = f.parent?.path ?? "";
|
|
// Don't offer our own export root's parent by mistake — the export
|
|
// subfolders themselves are valid, only the bare filename matters.
|
|
dirs.add(dir);
|
|
}
|
|
}
|
|
return [...dirs].sort();
|
|
}
|
|
|
|
private async importFolder(dir: string): Promise<void> {
|
|
// 1. world.json — identity + name (R1a).
|
|
const worldJsonPath = normalizePath(`${dir}/world.json`);
|
|
const worldFile = this.app.vault.getAbstractFileByPath(worldJsonPath);
|
|
if (!(worldFile instanceof TFile)) {
|
|
new Notice("world.json not found in the selected folder.");
|
|
return;
|
|
}
|
|
let worldMeta: Record<string, unknown>;
|
|
try {
|
|
worldMeta = JSON.parse(await this.app.vault.read(worldFile));
|
|
} catch (e) {
|
|
new Notice(`world.json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
return;
|
|
}
|
|
const worldId = worldMeta.id;
|
|
const worldName = typeof worldMeta.name === "string" && worldMeta.name.trim()
|
|
? worldMeta.name.trim()
|
|
: null;
|
|
if (!isUuid(worldId) || !worldName) {
|
|
new Notice("world.json must carry a valid `id` (uuid) and `name`.", 8000);
|
|
return;
|
|
}
|
|
|
|
// 2. Target world folder — create if absent; abort on id mismatch (R5).
|
|
const targetWorld = worldName;
|
|
const worldFolderPath = normalizePath(`OnlyWorlds/Worlds/${targetWorld}`);
|
|
const existingId = await this.readWorldIdMarker(targetWorld);
|
|
if (existingId && existingId !== worldId) {
|
|
new Notice(
|
|
`A world named "${targetWorld}" already exists with a different id. ` +
|
|
"Import aborted — two different worlds must not be merged.",
|
|
12000
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 3. Collect element JSON files under elements/ and spatial/.
|
|
const elementFiles = this.app.vault
|
|
.getFiles()
|
|
.filter(
|
|
(f) =>
|
|
f.extension === "json" &&
|
|
(f.path.startsWith(`${dir}/elements/`) || f.path.startsWith(`${dir}/spatial/`))
|
|
);
|
|
if (elementFiles.length === 0) {
|
|
new Notice("The folder contains no element JSON files (elements/ or spatial/).");
|
|
return;
|
|
}
|
|
|
|
// Pass 1 — parse every element, build the id -> ref index for ow:// resolution.
|
|
const parsed: { type: string; payload: Record<string, unknown> }[] = [];
|
|
const idIndex = new Map<string, ElementRef>();
|
|
const failed: { path: string; reason: string }[] = [];
|
|
for (const file of elementFiles) {
|
|
try {
|
|
const json = JSON.parse(await this.app.vault.read(file)) as Record<string, unknown>;
|
|
// Fallback type from the containing bucket folder: <dir>/<bucket>/<type>/file.
|
|
const rel = file.path.slice(dir.length + 1); // strip "<dir>/"
|
|
const fallbackType = normalizeCategory(rel.split("/")[1] ?? "");
|
|
const { type, payload } = parseFolderElementBody(json, fallbackType);
|
|
const id = payload.id;
|
|
const name = payload.name;
|
|
if (!isUuid(id) || typeof name !== "string") {
|
|
failed.push({ path: file.path, reason: "missing valid id or name" });
|
|
continue;
|
|
}
|
|
parsed.push({ type, payload });
|
|
idIndex.set(id, { id, name, type });
|
|
} catch (e) {
|
|
failed.push({ path: file.path, reason: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
}
|
|
if (parsed.length === 0) {
|
|
new Notice("Import aborted — no readable elements in the folder.");
|
|
return;
|
|
}
|
|
|
|
// Existing element ids in the target world (R5 skip-existing).
|
|
const existingIds = await this.existingElementIds(targetWorld);
|
|
|
|
// Pass 2 — translate prose ow:// -> [[wikilink]], mint notes via writeElement.
|
|
const result = { created: [] as string[], skipped: [] as string[], failed };
|
|
let createdMarker = existingId !== null;
|
|
for (const el of parsed) {
|
|
const id = String(el.payload.id);
|
|
if (existingIds.has(id)) {
|
|
result.skipped.push(id);
|
|
continue;
|
|
}
|
|
try {
|
|
const payload = { ...el.payload };
|
|
const bodyField = bodyFieldForCategory(el.type);
|
|
if (typeof payload[bodyField] === "string") {
|
|
payload[bodyField] = owMentionsToWikilinks(
|
|
payload[bodyField] as string,
|
|
(i) => idIndex.get(i) ?? null
|
|
);
|
|
}
|
|
await writeElement(this.app, targetWorld, el.type, id, payload, {
|
|
markSelfWrite: this.markSelfWrite,
|
|
});
|
|
result.created.push(id);
|
|
existingIds.add(id);
|
|
// Stamp the world-id marker once, after the first successful write
|
|
// (so the folder exists) — makes the id-mismatch guard stable.
|
|
if (!createdMarker) {
|
|
await this.writeWorldIdMarker(targetWorld, worldId);
|
|
createdMarker = true;
|
|
}
|
|
} catch (e) {
|
|
result.failed.push({ path: id, reason: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
}
|
|
|
|
new ImportReportModal(this.app, targetWorld, result).open();
|
|
}
|
|
|
|
/** All element ids already present in the target world's notes (R5). */
|
|
private async existingElementIds(world: string): Promise<Set<string>> {
|
|
const ids = new Set<string>();
|
|
const prefix = `OnlyWorlds/Worlds/${world}/Elements/`;
|
|
for (const f of this.app.vault.getMarkdownFiles()) {
|
|
if (!f.path.startsWith(prefix)) continue;
|
|
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
|
|
if (fm && typeof fm.id === "string") ids.add(fm.id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
// Marker I/O lives in vault/world-id-marker.ts (shared with export — the
|
|
// stable-identity ruling). Adapter-only for the dotfile reasons documented there.
|
|
private readWorldIdMarker(world: string): Promise<string | null> {
|
|
return readWorldIdMarker(this.app, world);
|
|
}
|
|
|
|
private writeWorldIdMarker(world: string, worldId: string): Promise<void> {
|
|
return writeWorldIdMarker(this.app, world, worldId);
|
|
}
|
|
}
|
|
|
|
/** Folder picker over world.json-bearing candidates. */
|
|
class FolderPickModal extends Modal {
|
|
constructor(app: App, private candidates: string[], private onPick: (dir: string) => void) {
|
|
super(app);
|
|
}
|
|
|
|
onOpen() {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
contentEl.createEl("h2", { text: "Import OnlyWorlds folder" });
|
|
const p = contentEl.createEl("p", {
|
|
text:
|
|
"Pick a folder (one containing a world.json) to import as frontmatter notes. " +
|
|
"Existing notes are never overwritten — matching ids are skipped.",
|
|
});
|
|
p.style.fontSize = "0.9em";
|
|
for (const dir of this.candidates) {
|
|
new Setting(contentEl).setName(dir || "(vault root)").addButton((b) =>
|
|
b
|
|
.setButtonText("Import")
|
|
.setCta()
|
|
.onClick(() => {
|
|
this.close();
|
|
this.onPick(dir);
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
onClose() {
|
|
this.contentEl.empty();
|
|
}
|
|
}
|
|
|
|
/** End-of-run report (R5 shape: N created / N skipped-existing / N failed). */
|
|
class ImportReportModal extends Modal {
|
|
constructor(
|
|
app: App,
|
|
private world: string,
|
|
private result: { created: string[]; skipped: string[]; failed: { path: string; reason: string }[] }
|
|
) {
|
|
super(app);
|
|
}
|
|
|
|
onOpen() {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
contentEl.createEl("h2", { text: `Imported — ${this.world}` });
|
|
|
|
const summary = contentEl.createEl("ul");
|
|
summary.createEl("li", { text: `${this.result.created.length} created` });
|
|
summary.createEl("li", { text: `${this.result.skipped.length} skipped (id already present)` });
|
|
summary.createEl("li", { text: `${this.result.failed.length} failed` });
|
|
|
|
if (this.result.failed.length > 0) {
|
|
contentEl.createEl("h3", { text: "Failed elements" });
|
|
const list = contentEl.createEl("ul");
|
|
for (const f of this.result.failed) list.createEl("li", { text: `${f.path} — ${f.reason}` });
|
|
}
|
|
|
|
new Setting(contentEl).addButton((b) =>
|
|
b.setButtonText("Close").setCta().onClick(() => this.close())
|
|
);
|
|
}
|
|
|
|
onClose() {
|
|
this.contentEl.empty();
|
|
}
|
|
}
|