onlyworlds_obsidian-plugin/Commands/SaveElementCommand.ts
Titus 59feec9338 Phase B: frontmatter flip (span -> YAML frontmatter)
Wake the dormant vault/element-file.ts write path and make YAML frontmatter
the note format for new writes, with read-tolerance for legacy span notes.

Pure logic (Obsidian-free, unit-tested under `npm test`):
- vault/element-transform.ts: frontmatter<->payload, span parse, span->frontmatter,
  link normalization (single string / multi list, never comma-joined), extension-key
  passthrough (atlas_/shadow_/x_), body<->description|story mapping, read-before-PATCH diff.
- test/element-transform.test.ts + tsconfig.test.json: 16 node:test cases incl. a real
  span-format Character fixture. Wired to `npm test` (pretest compiles to test-dist/).

Wiring:
- element-file.ts: readElement preserves extension fields + maps body to story for
  Narrative (R3/R5); frontmatter-first with legacy span fallback; writeElement round-trips
  extension keys and respects count-suffixed folders.
- DownloadWorldCommand + CreateElementCommand: emit frontmatter via writeElement (no more
  Handlebars element templates).
- NoteLinker: reworked onto metadataCache + SDK FIELD_SCHEMA; writes link IDs into
  frontmatter (new FieldSelectionModal picks the field). NameChanger syncs frontmatter name.
- SaveElementCommand: read-before-PATCH (GET, diff, PATCH only changed fields) (R7).
- ExportWorldCommand + CopyWorldCommand: read frontmatter via readElement, span as fallback
  (mixed worlds upload/copy correctly).
- MigrateWorldCommand: "Migrate world notes to frontmatter" — backup-first (abort on failure),
  idempotent, end report modal (converted/skipped/failed).

Deferred (see docs report): PasteWorld still emits span; ValidateWorld is still a span linter
(diagnostic only, no writes). docs/SMOKE-CHECKLIST.md is the manual release gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:43:56 +02:00

418 lines
20 KiB
TypeScript

import { App, Notice, TFile, TFolder, normalizePath } from 'obsidian';
import type OnlyWorldsPlugin from '../main';
import { readElement } from '../vault/element-file';
import { resolveWorldKey } from '../vault/world-key';
import { sanitizeFileName } from '../Scripts/WorldService';
import { decodeHtmlEntities } from '../Scripts/htmlEntities';
import { toV2Payload, V2ApiError } from '../client-v2';
import { diffPayload } from '../vault/element-transform';
// Define the structure for element data (adapt as needed based on actual fields)
interface ElementData {
id?: string; // ID is handled separately for the URL path
name?: string;
[key: string]: unknown; // Allow other fields
}
// Define the structure for world file data (adapt as needed)
interface WorldFileData {
api_key?: string;
[key: string]: unknown;
}
export class SaveElementCommand {
app: App;
plugin: OnlyWorldsPlugin;
constructor(app: App, plugin: OnlyWorldsPlugin) {
this.app = app;
this.plugin = plugin;
}
// Helper method to convert strings to snake_case
toSnakeCase(input: string): string {
// Handle known specific cases first
if (input === "API Key") return "api_key";
if (input === "Id") return "id"; // Although ID is removed from payload later
// General conversion: Add space before uppercase, lowercase, replace space with underscore
return input
// Add a space before uppercase letters (but not if it's the start of the string)
.replace(/([A-Z])/g, ' $1')
.trim() // Remove potential leading/trailing spaces
.toLowerCase() // Convert to lowercase
.replace(/[\s\-]+/g, '_') // Replace spaces and hyphens with underscores
.replace(/_+/g, '_'); // Collapse multiple underscores (e.g., from acronyms)
}
async execute() {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile || !(activeFile instanceof TFile)) {
new Notice("No active file selected or it's not a valid file.");
return;
}
// 1. Validate Path and Extract Info
const pathInfo = this.extractPathInfo(activeFile.path);
if (!pathInfo) {
new Notice("The current file is not a valid OnlyWorlds element note.");
return;
}
const { worldName, category } = pathInfo;
// 2. Try v2 (frontmatter) first; fall back to v1 (span-tag) if no frontmatter id.
let elementData: ElementData | null = null;
const v2 = await readElement(this.app, activeFile);
if (v2 && v2.id) {
elementData = { id: v2.id, ...v2.fields };
} else {
const fileContent = await this.app.vault.read(activeFile);
elementData = await this.parseElementContent(fileContent, activeFile.path);
}
if (!elementData) {
new Notice("Could not parse element data from the note.");
console.error("Parsing error in:", activeFile.path);
return;
}
const elementUuid = elementData.id;
if (!elementUuid) {
new Notice("Could not find element ID in the note after parsing.");
console.error("Missing ID in parsed data:", elementData);
return;
}
// Remove ID from payload as it's in the URL path
delete elementData.id;
// 3. Resolve API Key. THIS world's World.md wins — in a multi-world vault
// the settings key may belong to a different world, and the API routes a
// write to whatever world the key names (a save under world B's folder
// with world A's settings key lands the element in world A — the
// wrong-world class, hit live 2026-07-12). Settings key is fallback ONLY
// when the world carries no key of its own; the fallback warns.
const resolved = await resolveWorldKey(this.app, worldName, this.plugin.settings.apiKey);
const apiKey = resolved.apiKey ?? undefined;
if (!apiKey) {
new Notice("Error getting API key: no API key in this world's World.md or in plugin settings.");
return;
}
if (!resolved.ownWorld) {
new Notice(`This world has no API key of its own; using your default key. Add its key to World.md to be sure edits land in the right world.`, 8000);
}
// 4. Build v2 client (pulls cached PIN, or prompts once per session)
const client = await this.plugin.buildV2Client(apiKey);
if (!client) {
new Notice("Save cancelled: PIN not provided.");
return;
}
// 5. v2 wire payload: bare link names (suffixes stripped), world stripped.
const type = category.toLowerCase();
const payload = toV2Payload(elementData as Record<string, unknown>);
const label = elementData.name || elementUuid;
// 6. Read-before-PATCH (R7): fetch current server state, send only the
// fields that actually changed. This leaves server-only fields (incl.
// extension namespaces the note may not carry) untouched, instead of a
// wholesale overwrite from the local parse. A GET 404 means the element
// was never pushed — create it (the server preserves client-supplied ids).
let current: Record<string, unknown> | null = null;
try {
current = await client.get(type, elementUuid);
} catch (error) {
if (this.isNotFound(error)) {
try {
new Notice(`Creating ${category} "${label}"...`);
await client.create(type, { id: elementUuid, ...payload });
new Notice(`${category} created.`);
} catch (createError) {
console.error('Error during v2 create fallback:', createError);
const msg = createError instanceof Error ? createError.message : 'Unknown error';
new Notice(`Failed to create ${category}: ${msg}`, 10000);
}
return;
}
console.error('Error during v2 GET (read-before-PATCH):', error);
const msg = error instanceof Error ? error.message : 'Unknown error';
new Notice(`Failed to save ${category}: ${msg}`, 10000);
return;
}
const changed = diffPayload(payload, current ?? {});
if (Object.keys(changed).length === 0) {
new Notice(`${category} "${label}" already up to date.`);
return;
}
try {
new Notice(`Saving ${category} "${label}"...`);
await client.update(type, elementUuid, changed);
new Notice(`${category} saved.`);
} catch (error) {
console.error('Error during v2 update call:', error);
const msg = error instanceof Error ? error.message : 'Unknown error';
new Notice(`Failed to save ${category}: ${msg}`, 10000);
}
}
// Match a genuine 404 so we don't fall back to create() on other failures.
// V2ApiError carries the real status; the legacy message check stays as a
// safety net for any non-V2ApiError wrapping.
private isNotFound(error: unknown): boolean {
if (error instanceof V2ApiError) {
return error.status === 404;
}
return error instanceof Error && /^API Error 404\b/.test(error.message);
}
// Helper to extract World Name and Category from path
extractPathInfo(filePath: string): { worldName: string; category: string } | null {
// Example path: OnlyWorlds/Worlds/MyWorld/Elements/Character (3)/Hero.md
const pattern = /^OnlyWorlds\/Worlds\/([^\/]+)\/Elements\/([^\/]+)\/.+\.md$/i;
const match = filePath.match(pattern);
if (match && match[1] && match[2]) {
// Strip count suffix from category name (e.g., "Character (3)" -> "Character")
const rawCategory = match[2];
const baseCategoryName = rawCategory.replace(/\s*\(\d+\)$/, '');
// console.log(`[SaveElementCommand] Extracted category: "${rawCategory}" -> base: "${baseCategoryName}"`);
return { worldName: match[1], category: baseCategoryName };
}
return null;
}
// Refined parser based on ExportWorldCommand's logic
async parseElementContent(content: string, currentFilePath: string): Promise<ElementData | null> {
// console.log("Starting element parsing (v2)...");
const data: ElementData = {};
const lines = content.split('\n');
// Regex to capture key, value, and tooltip:
const linePattern = /- <span class="[^"]+" data-tooltip="([^"]+)">([^<]+)<\/span>:\s*(.*)/;
const idPatternSimple = /Id<\/span>:\s*([a-f0-9\-]{36})/; // Separate pattern just for ID
let foundId: string | null = null; // Track ID separately
for (const line of lines) {
// First, specifically check for the ID line
const idMatchSimple = line.match(idPatternSimple);
if (idMatchSimple && idMatchSimple[1]) {
foundId = idMatchSimple[1].trim();
// console.log(` -> Found ID via simple pattern: ${foundId}`);
continue; // Don't process the ID line with the general pattern
}
// Process general key-value lines
const match = line.match(linePattern);
if (match) {
const tooltip = match[1].trim();
const key = match[2].trim();
// Decode entities from notes written before noEscape: a name/link
// stored as "The Kid&#x27;s Family" must become raw text so the
// wikilink file lookup matches and the API stores the real name.
const rawValue = decodeHtmlEntities(match[3].trim());
let snakeKey = this.toSnakeCase(key);
// Skip empty values, API expects null.
// TTRPG stats need uppercase keys even when null.
if (!rawValue || rawValue.toLowerCase() === 'none') {
const rawKeyLetters = key.replace(/[^a-zA-Z]/g, '').toUpperCase();
const ttrpgStats = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
const finalKey = ttrpgStats.includes(rawKeyLetters) ? rawKeyLetters : snakeKey;
data[finalKey] = null;
continue;
}
// console.log(` -> Field [${key}] (Tooltip: ${tooltip}): Raw value = "${rawValue}"`);
// --- Handle Links based on Tooltip ---
if (tooltip.toLowerCase().startsWith('single ') || tooltip.toLowerCase().startsWith('multi ')) {
// Get the IDs first
const ids = await this.extractLinkedIds(rawValue, tooltip, currentFilePath);
// Determine the final key and assign value based on single/multi
if (tooltip.toLowerCase().startsWith('single ')) {
const finalKey = `${snakeKey}_id`; // Append _id for single links
data[finalKey] = ids.length > 0 ? ids[0] : null;
// console.log(` Recognized as Single Link field. Key: ${finalKey}, Assigned ID = ${data[finalKey]}`);
} else { // Must be multi
const finalKey = `${snakeKey}_ids`; // Append _ids for multi links
data[finalKey] = ids; // Assign array
// console.log(` Recognized as Multi Link field. Key: ${finalKey}, Assigned IDs = [${ids.join(', ')}]`);
}
}
// --- Handle Numbers ---
else if (tooltip.toLowerCase() === 'number') {
const num = parseInt(rawValue, 10);
// TTRPG stats — API expects uppercase STR/DEX/CON/INT/WIS/CHA.
// The raw field name in the span could be "Str", "STR", "str", or "s_t_r"
// (the last from snake_casing all-caps input). Normalize from the raw key.
const rawKeyLetters = key.replace(/[^a-zA-Z]/g, '').toUpperCase();
const ttrpgStats = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
const isTtrpgStat = ttrpgStats.includes(rawKeyLetters);
const finalKey = isTtrpgStat ? rawKeyLetters : snakeKey;
if (!isNaN(num) && /^\d+$/.test(rawValue)) {
data[finalKey] = num;
} else {
console.warn(` Could not parse number for key "${key}": ${rawValue}. Assigning null.`);
data[finalKey] = null;
}
}
// --- Handle Text (Default) ---
else {
data[snakeKey] = rawValue;
// console.log(` Recognized as Text: Assigned value = "${rawValue}"`);
}
}
}
// Add the separately found ID to the data object
if (foundId) {
data.id = foundId;
} else {
// Fallback: Try to find ID again if not found via simple pattern (less likely now)
const idFallbackMatch = content.match(/Id<\/span>:\s*([a-f0-9\-]{36})/);
if (idFallbackMatch && idFallbackMatch[1]) {
data.id = idFallbackMatch[1].trim();
// console.log(` -> Found ID via fallback pattern: ${data.id}`);
}
}
// Ensure name is present (simple extraction)
if (!data.name) {
const nameMatch = content.match(/Name<\/span>:\s*(.+)/);
if (nameMatch && nameMatch[1]) {
data.name = decodeHtmlEntities(nameMatch[1].trim().split('<')[0].trim());
// console.log(` -> Found Name: ${data.name}`);
}
}
// console.log("Finished element parsing (v2).");
if (!data.id) {
console.error(" -> CRITICAL: Element ID could not be found after parsing.");
}
return data.id ? data : null; // Return only if ID was found
}
// Refined ID extraction based on ExportWorldCommand logic
async extractLinkedIds(linkedText: string, tooltip: string, currentFilePath: string): Promise<string[]> {
// console.log(` Extracting IDs (v2) for Links: "${linkedText}"`);
const ids: string[] = [];
const linkPattern = /\[\[(.*?)\]\]/g; // Find [[Note Name]]
let match;
const pathInfo = this.extractPathInfo(currentFilePath);
if (!pathInfo) {
console.error(" Cannot extract IDs: World context not found.");
return ids;
}
const { worldName } = pathInfo;
// Determine the linked category from the tooltip (e.g., "Multi Link Traits" -> "Traits")
const tooltipParts = tooltip.split(' ');
const linkedCategory = tooltipParts.length > 1 ? tooltipParts[tooltipParts.length - 1] : null;
if (!linkedCategory) {
console.warn(` Cannot extract IDs: Linked category not determined from tooltip: "${tooltip}"`);
return ids;
}
// console.log(` Determined linked category: ${linkedCategory}`);
while ((match = linkPattern.exec(linkedText)) !== null) {
const noteName = match[1];
// A uuid-shaped link IS the id (download writes [[<id>]] when it
// can't resolve a name) — pass through, identity preserved.
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(noteName)) {
ids.push(noteName);
continue;
}
// Find the actual category folder (which might have count suffix)
const actualCategoryFolder = await this.findCategoryFolderByBaseName(worldName, linkedCategory);
if (!actualCategoryFolder) {
console.warn(` Cannot find category folder for: ${linkedCategory}`);
continue;
}
// Wikilink text is the filename form; sanitize for the path lookup.
const linkedFilePath = normalizePath(`${actualCategoryFolder}/${sanitizeFileName(noteName)}.md`);
// console.log(` Looking for linked file at: ${linkedFilePath}`);
try {
const linkedFile = this.app.vault.getAbstractFileByPath(linkedFilePath);
if (linkedFile instanceof TFile) {
// console.log(` Found linked file.`);
const fileContent = await this.app.vault.read(linkedFile);
// Use the simpler ID extraction function (like ExportWorldCommand's parseElement)
const linkedElementData = this.parseElementIdAndName(fileContent); // NEW helper call
if (linkedElementData && linkedElementData.id !== "Unknown Id") {
ids.push(linkedElementData.id);
// console.log(` Successfully extracted ID: ${linkedElementData.id}`);
} else {
console.warn(` Could not extract valid ID from linked file: ${linkedFilePath}`);
}
} else {
console.warn(` Linked file not found or is not a file: ${linkedFilePath}`);
}
} catch (error) {
console.error(` Error reading or parsing linked file ${linkedFilePath}:`, error);
}
}
// console.log(` Finished extracting IDs (v2). Found: [${ids.join(', ')}]`);
return ids;
}
// Helper to parse just ID and Name from content (like ExportWorldCommand)
private parseElementIdAndName(content: string): { id: string, name: string } {
// console.log(" Parsing linked element ID/Name...");
// Match ID: Use the regex from ExportWorldCommand
const idMatch = content.match(/<span class="text-field" data-tooltip="Text">Id<\/span>:\s*([^\r\n<]+)/);
// Match Name: Use the regex from ExportWorldCommand
const nameMatch = content.match(/<span class="text-field" data-tooltip="Text">Name<\/span>:\s*([^\r\n<]+)/);
const id = idMatch && idMatch[1] ? idMatch[1].trim() : "Unknown Id";
const name = nameMatch && nameMatch[1] ? nameMatch[1].trim() : "Unnamed Element";
// console.log(` Found ID: ${id}, Name: ${name}`);
return { id, name };
}
// Parser for World.md
parseWorldFile(content: string): WorldFileData | null {
const data: WorldFileData = {};
const apiKeyRegex = /- \*\*API Key:\*\* (\S+)/; // Match "- **API Key:** <key>" (ow_ or classic)
const match = content.match(apiKeyRegex);
if (match && match[1]) {
data.api_key = match[1].trim();
}
return Object.keys(data).length > 0 ? data : null; // Return null if no key found
}
// Helper method to find category folder by base name (handles count suffixes)
async findCategoryFolderByBaseName(worldName: string, baseCategoryName: string): Promise<string | null> {
const elementsPath = normalizePath(`OnlyWorlds/Worlds/${worldName}/Elements`);
const elementsFolder = this.app.vault.getAbstractFileByPath(elementsPath);
if (!(elementsFolder instanceof TFolder)) {
console.warn(`Elements folder not found or not a folder: ${elementsPath}`);
return null;
}
for (const child of elementsFolder.children) {
if (child instanceof TFolder) {
// Check if folder name starts with the base category name
if (child.name === baseCategoryName || child.name.startsWith(`${baseCategoryName} (`)) {
// console.log(`[SaveElementCommand] Found category folder: ${child.path}`);
return child.path;
}
}
}
console.warn(`[SaveElementCommand] Category folder not found for: ${baseCategoryName}`);
return null;
}
}