onlyworlds_obsidian-plugin/Commands/SaveElementCommand.ts
2025-07-08 19:31:49 +02:00

432 lines
No EOL
20 KiB
TypeScript

import { PinInputModal } from 'Modals/PinInputModal'; // This modal will be created next
import { App, Notice, TFile, TFolder, normalizePath, requestUrl } from 'obsidian';
// 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]: any; // Allow other fields
}
// Define the structure for world file data (adapt as needed)
interface WorldFileData {
api_key?: string;
[key: string]: any;
}
export class SaveElementCommand {
app: App;
// DEVELOPMENT: Point to local server
// private apiBaseUrl = 'http://127.0.0.1:8000/api/worldapi/';
// PRODUCTION: Point to production server
private apiBaseUrl = 'https://www.onlyworlds.com/api/worldapi/';
// private apiBaseUrl = 'https://onlywords.pythonanywhere.com/api/worldapi/';
constructor(app: App) {
this.app = app;
}
// 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() {
console.log("Executing SaveElementCommand...");
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;
}
console.log(`Processing file: ${activeFile.path}`);
// 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.");
console.log(`Invalid path: ${activeFile.path}`);
return;
}
const { worldName, category } = pathInfo;
console.log(`Extracted Path Info: World=${worldName}, Category=${category}`);
// 2. Read and Parse Element Content using the refined parser
const fileContent = await this.app.vault.read(activeFile);
const 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;
}
console.log(`Element UUID for URL: ${elementUuid}`);
// Remove ID from payload as it's in the URL path
delete elementData.id;
// 3. Get API Key from World.md
const worldFilePath = normalizePath(`OnlyWorlds/Worlds/${worldName}/World.md`);
console.log(`Looking for World.md at: ${worldFilePath}`);
let apiKey: string | undefined;
try {
const worldFile = this.app.vault.getAbstractFileByPath(worldFilePath);
if (worldFile instanceof TFile) {
const worldFileContent = await this.app.vault.read(worldFile);
const worldData = this.parseWorldFile(worldFileContent);
apiKey = worldData?.api_key;
if (!apiKey) {
throw new Error("API Key field not found or empty in World.md.");
}
console.log(`Found API Key: ${apiKey}`);
} else {
throw new Error("World.md file not found or is a folder.");
}
} catch (error) {
console.error(`Error accessing or parsing World.md for ${worldName}:`, error);
new Notice(`Error getting API key: ${error instanceof Error ? error.message : 'Unknown error'}. Please check World.md.`);
return;
}
// 4. Prompt for PIN
console.log("Prompting for PIN...");
new PinInputModal(this.app, async (pin: string | null) => {
if (!pin) {
new Notice("Save cancelled: PIN not provided.");
console.log("PIN prompt cancelled by user.");
return;
}
console.log("PIN provided.");
// 5. Construct URL and Payload
const apiUrl = `${this.apiBaseUrl}${category.toLowerCase()}/${elementUuid}/`;
const payload = { ...elementData }; // Use the parsed data
console.log("--- Sending Payload ---");
console.log(JSON.stringify(payload, null, 2));
console.log("Target URL:", apiUrl);
console.log("API Key:", apiKey); // Don't log PIN
console.log("-----------------------");
// 6. Make API Call
try {
new Notice(`Saving ${category} "${elementData.name || elementUuid}"...`);
const response = await requestUrl({
url: apiUrl,
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'API-Key': apiKey,
'API-Pin': pin
},
body: JSON.stringify(payload),
});
console.log(`API Response Status: ${response.status}`);
// 7. Handle Response
if (response.status === 200 || response.status === 201) {
const responseData = response.json;
console.log("API Success Response:", responseData);
const message = responseData?.message || `Element ${response.status === 201 ? 'created' : 'updated'} successfully.`;
new Notice(message);
} else {
console.log("API Error Response:", response.json);
this.handleApiError(response.status, response.json);
}
} catch (error) {
console.error('Error during API requestUrl call:', error);
if (error && typeof error === 'object' && 'status' in error && typeof error.status === 'number') {
// Try to get status and body from caught error if requestUrl failed structurally
let responseBody = {};
try {
if (typeof error.body === 'string') responseBody = JSON.parse(error.body);
else if (typeof error.body === 'object') responseBody = error.body;
} catch(parseErr) { console.error("Failed to parse error body:", parseErr)}
this.handleApiError(error.status, responseBody);
} else if (error instanceof Error) {
new Notice(`Network or processing error: ${error.message}`);
} else {
new Notice('An unknown error occurred while saving the element.');
}
}
}).open();
}
// 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();
const rawValue = match[3].trim();
let snakeKey = this.toSnakeCase(key);
// Skip empty values, API expects null
if (!rawValue || rawValue.toLowerCase() === 'none') {
console.log(` -> Field [${key}]: Skipping empty value.`);
data[snakeKey] = 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);
if (!isNaN(num) && /^\d+$/.test(rawValue)) {
data[snakeKey] = num;
console.log(` Recognized as Number: Assigned value = ${num}`);
} else {
console.warn(` Could not parse number for key "${key}": ${rawValue}. Assigning null.`);
data[snakeKey] = 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 = 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];
console.log(` Found link: [[${noteName}]]`);
// 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;
}
const linkedFilePath = normalizePath(`${actualCategoryFolder}/${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*([^\s<]+)/);
// Match Name: Use the regex from ExportWorldCommand
const nameMatch = content.match(/<span class="text-field" data-tooltip="Text">Name<\/span>:\s*([^\s<]+)/);
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:\*\* (\d+)/; // Match "- **API Key:** digits"
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;
}
// Error Handler
handleApiError(status: number, responseData: any) {
let message = `Error saving element (Status ${status}).`;
const responseMessage = responseData?.message || responseData?.detail || '';
let detailMessage = '';
if (typeof responseData?.detail === 'string') {
try {
const details = JSON.parse(responseData.detail);
if (Array.isArray(details)) {
detailMessage = details.map((err: any) => `${err.loc?.slice(1).join('.') || 'error'}: ${err.msg}`).join('; ');
} else { detailMessage = responseData.detail; }
} catch (e) { detailMessage = responseData.detail; }
} else if (responseData?.detail) {
try { detailMessage = JSON.stringify(responseData.detail); } catch (e) { detailMessage = 'Invalid error detail format'; }
}
switch (status) {
case 400: message = `Bad Request: ${detailMessage || responseMessage || 'Invalid data sent.'}`; break;
case 401:
case 403: message = `Authentication Failed: ${responseMessage || 'Invalid API Key or PIN.'}`; break;
case 404: message = `Not Found: ${responseMessage || 'Element UUID or API endpoint not found.'}`; break;
case 422: message = `Validation Error: ${detailMessage || responseMessage || 'Invalid data format.'}`; break;
case 429: message = `Rate Limit Exceeded: ${responseMessage || 'Please try again later.'}`; break;
case 500:
default: message = `Server Error (Status ${status}): ${responseMessage || 'Failed to save element on the server.'}`; break;
}
console.error("API Error Details:", status, responseData);
new Notice(message, 10000);
}
}