mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-17 09:41:24 +01:00
Problem: Every time you click on a burg/marker, it had to scan through all 13,496 vault files looking for a matching fmg-id. This was extremely slow and made the feature unusable for large vaults. Solution: Implement a smart index that maps fmg-id to file paths. How it works: - Index stored in memory (fmgIdIndex object) - Persisted to localStorage (survives page reloads) - Loaded on init, saved on changes - When looking up by fmg-id: 1. Check index first (instant!) 2. If found, verify file still exists and ID matches 3. If not in index, search vault and add to index - Automatically updates when notes are created/saved through FMG - Handles stale entries (file deleted/modified) Performance improvement: - Before: O(n) - scan all 13k files (very slow) - After: O(1) - instant lookup from index - First click on burg: May need to search (builds index) - Second click on same burg: Instant! Opens note directly This makes the Obsidian integration actually usable. Create a note once for a burg, and every time you click that burg again, it opens instantly.
646 lines
18 KiB
JavaScript
646 lines
18 KiB
JavaScript
"use strict";
|
|
|
|
// Obsidian Vault Integration for Fantasy Map Generator
|
|
// Uses Obsidian Local REST API plugin
|
|
// https://github.com/coddingtonbear/obsidian-local-rest-api
|
|
|
|
const ObsidianBridge = (() => {
|
|
// Configuration
|
|
const config = {
|
|
apiUrl: "http://127.0.0.1:27123",
|
|
apiKey: "", // Set via UI
|
|
enabled: false,
|
|
vaultName: ""
|
|
};
|
|
|
|
// Cache for vault file list
|
|
let vaultFilesCache = {
|
|
files: null,
|
|
timestamp: null,
|
|
ttl: 5 * 60 * 1000 // 5 minutes cache
|
|
};
|
|
|
|
// Index: fmg-id → file path for fast lookups
|
|
let fmgIdIndex = {};
|
|
|
|
// Initialize from localStorage
|
|
function init() {
|
|
const stored = localStorage.getItem("obsidianConfig");
|
|
if (stored) {
|
|
try {
|
|
const parsed = JSON.parse(stored);
|
|
Object.assign(config, parsed);
|
|
} catch (error) {
|
|
ERROR && console.error("Failed to load Obsidian config:", error);
|
|
}
|
|
}
|
|
|
|
// Load FMG ID index from localStorage
|
|
const storedIndex = localStorage.getItem("obsidianFmgIdIndex");
|
|
if (storedIndex) {
|
|
try {
|
|
fmgIdIndex = JSON.parse(storedIndex);
|
|
INFO && console.log(`Loaded FMG ID index with ${Object.keys(fmgIdIndex).length} entries`);
|
|
} catch (error) {
|
|
ERROR && console.error("Failed to load FMG ID index:", error);
|
|
fmgIdIndex = {};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save configuration
|
|
function saveConfig() {
|
|
localStorage.setItem("obsidianConfig", JSON.stringify(config));
|
|
}
|
|
|
|
// Test connection to Obsidian
|
|
async function testConnection() {
|
|
if (!config.apiKey) {
|
|
throw new Error("API key not set. Please configure in Options.");
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${config.apiUrl}/`, {
|
|
headers: {
|
|
Authorization: `Bearer ${config.apiKey}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Connection failed: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
INFO && console.log("Obsidian connection successful:", data);
|
|
config.enabled = true;
|
|
saveConfig();
|
|
return true;
|
|
} catch (error) {
|
|
ERROR && console.error("Obsidian connection failed:", error);
|
|
config.enabled = false;
|
|
saveConfig();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Recursively scan a directory and all subdirectories for .md files
|
|
async function scanDirectory(path = "") {
|
|
const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(path)}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${config.apiKey}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch directory ${path}: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const entries = data.files || [];
|
|
const mdFiles = [];
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path ? `${path}${entry}` : entry;
|
|
|
|
if (entry.endsWith("/")) {
|
|
// It's a directory - recurse into it
|
|
DEBUG && console.log(`Scanning directory: ${fullPath}`);
|
|
const subFiles = await scanDirectory(fullPath);
|
|
mdFiles.push(...subFiles);
|
|
} else if (entry.endsWith(".md")) {
|
|
// It's a markdown file - add it
|
|
mdFiles.push(fullPath);
|
|
}
|
|
}
|
|
|
|
return mdFiles;
|
|
}
|
|
|
|
// Clear the vault files cache
|
|
function clearVaultCache() {
|
|
vaultFilesCache.files = null;
|
|
vaultFilesCache.timestamp = null;
|
|
INFO && console.log("Vault file cache cleared");
|
|
}
|
|
|
|
// Save FMG ID index to localStorage
|
|
function saveFmgIdIndex() {
|
|
try {
|
|
localStorage.setItem("obsidianFmgIdIndex", JSON.stringify(fmgIdIndex));
|
|
DEBUG && console.log(`Saved FMG ID index with ${Object.keys(fmgIdIndex).length} entries`);
|
|
} catch (error) {
|
|
ERROR && console.error("Failed to save FMG ID index:", error);
|
|
}
|
|
}
|
|
|
|
// Add entry to FMG ID index
|
|
function addToFmgIdIndex(fmgId, filePath) {
|
|
if (!fmgId) return;
|
|
fmgIdIndex[fmgId] = filePath;
|
|
saveFmgIdIndex();
|
|
DEBUG && console.log(`Added to index: ${fmgId} → ${filePath}`);
|
|
}
|
|
|
|
// Get file path from FMG ID index
|
|
function getFromFmgIdIndex(fmgId) {
|
|
return fmgIdIndex[fmgId] || null;
|
|
}
|
|
|
|
// Get all markdown files from vault (recursively, with caching)
|
|
async function getVaultFiles(forceRefresh = false) {
|
|
if (!config.enabled) {
|
|
throw new Error("Obsidian not connected");
|
|
}
|
|
|
|
// Check cache
|
|
const now = Date.now();
|
|
const cacheValid = vaultFilesCache.files !== null &&
|
|
vaultFilesCache.timestamp !== null &&
|
|
(now - vaultFilesCache.timestamp) < vaultFilesCache.ttl;
|
|
|
|
if (cacheValid && !forceRefresh) {
|
|
INFO && console.log(`getVaultFiles: Using cached list (${vaultFilesCache.files.length} files)`);
|
|
return vaultFilesCache.files;
|
|
}
|
|
|
|
try {
|
|
TIME && console.time("getVaultFiles");
|
|
INFO && console.log("getVaultFiles: Scanning vault (cache miss or expired)...");
|
|
|
|
// Recursively scan all directories
|
|
const mdFiles = await scanDirectory("");
|
|
|
|
// Update cache
|
|
vaultFilesCache.files = mdFiles;
|
|
vaultFilesCache.timestamp = now;
|
|
|
|
INFO && console.log(`getVaultFiles: Found ${mdFiles.length} markdown files (recursive scan, cached)`);
|
|
DEBUG && console.log("Sample files:", mdFiles.slice(0, 10));
|
|
|
|
TIME && console.timeEnd("getVaultFiles");
|
|
|
|
return mdFiles;
|
|
} catch (error) {
|
|
ERROR && console.error("Failed to get vault files:", error);
|
|
TIME && console.timeEnd("getVaultFiles");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Get note content by path
|
|
async function getNote(notePath) {
|
|
if (!config.enabled) {
|
|
throw new Error("Obsidian not connected");
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(notePath)}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${config.apiKey}`,
|
|
Accept: "text/markdown"
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch note: ${response.status}`);
|
|
}
|
|
|
|
return await response.text();
|
|
} catch (error) {
|
|
ERROR && console.error("Failed to get note:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Update note content
|
|
async function updateNote(notePath, content) {
|
|
if (!config.enabled) {
|
|
throw new Error("Obsidian not connected");
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(notePath)}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${config.apiKey}`,
|
|
"Content-Type": "text/markdown"
|
|
},
|
|
body: content
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to update note: ${response.status}`);
|
|
}
|
|
|
|
INFO && console.log("Note updated successfully:", notePath);
|
|
return true;
|
|
} catch (error) {
|
|
ERROR && console.error("Failed to update note:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Create new note
|
|
async function createNote(notePath, content) {
|
|
if (!config.enabled) {
|
|
throw new Error("Obsidian not connected");
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(notePath)}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${config.apiKey}`,
|
|
"Content-Type": "text/markdown"
|
|
},
|
|
body: content
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to create note: ${response.status}`);
|
|
}
|
|
|
|
INFO && console.log("Note created successfully:", notePath);
|
|
return true;
|
|
} catch (error) {
|
|
ERROR && console.error("Failed to create note:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Parse YAML frontmatter from markdown content
|
|
function parseFrontmatter(content) {
|
|
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---/;
|
|
const match = content.match(frontmatterRegex);
|
|
|
|
if (!match) {
|
|
return {frontmatter: {}, content};
|
|
}
|
|
|
|
const frontmatterText = match[1];
|
|
const bodyContent = content.slice(match[0].length).trim();
|
|
|
|
// Simple YAML parser (handles basic key-value pairs and nested objects)
|
|
const frontmatter = {};
|
|
const lines = frontmatterText.split("\n");
|
|
let currentKey = null;
|
|
let currentObj = frontmatter;
|
|
let indentLevel = 0;
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
|
|
const indent = line.search(/\S/);
|
|
const colonIndex = trimmed.indexOf(":");
|
|
|
|
if (colonIndex === -1) continue;
|
|
|
|
const key = trimmed.slice(0, colonIndex).trim();
|
|
const value = trimmed.slice(colonIndex + 1).trim();
|
|
|
|
if (indent === 0) {
|
|
currentObj = frontmatter;
|
|
currentKey = key;
|
|
|
|
if (value) {
|
|
// Simple value
|
|
frontmatter[key] = parseValue(value);
|
|
} else {
|
|
// Nested object or array
|
|
frontmatter[key] = {};
|
|
currentObj = frontmatter[key];
|
|
}
|
|
} else if (currentObj && key) {
|
|
// Nested property
|
|
currentObj[key] = parseValue(value);
|
|
}
|
|
}
|
|
|
|
return {frontmatter, content: bodyContent};
|
|
}
|
|
|
|
// Parse YAML value (handle strings, numbers, arrays)
|
|
function parseValue(value) {
|
|
if (!value) return null;
|
|
|
|
// Array
|
|
if (value.startsWith("[") && value.endsWith("]")) {
|
|
return value
|
|
.slice(1, -1)
|
|
.split(",")
|
|
.map(v => v.trim())
|
|
.filter(v => v);
|
|
}
|
|
|
|
// Number
|
|
if (!isNaN(value) && value !== "") {
|
|
return parseFloat(value);
|
|
}
|
|
|
|
// Boolean
|
|
if (value === "true") return true;
|
|
if (value === "false") return false;
|
|
|
|
// String (remove quotes if present)
|
|
return value.replace(/^["']|["']$/g, "");
|
|
}
|
|
|
|
// Calculate distance between two coordinates
|
|
function calculateDistance(x1, y1, x2, y2) {
|
|
const dx = x2 - x1;
|
|
const dy = y2 - y1;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
// Find notes by coordinates
|
|
async function findNotesByCoordinates(x, y, limit = 8) {
|
|
TIME && console.time("findNotesByCoordinates");
|
|
|
|
try {
|
|
const files = await getVaultFiles();
|
|
const mdFiles = files.filter(f => f.endsWith(".md"));
|
|
|
|
const matches = [];
|
|
|
|
for (const filePath of mdFiles) {
|
|
try {
|
|
const content = await getNote(filePath);
|
|
const {frontmatter} = parseFrontmatter(content);
|
|
|
|
// Check for coordinates in frontmatter
|
|
let noteX, noteY;
|
|
|
|
// Support various coordinate formats
|
|
if (frontmatter.coordinates) {
|
|
noteX = frontmatter.coordinates.x || frontmatter.coordinates.X;
|
|
noteY = frontmatter.coordinates.y || frontmatter.coordinates.Y;
|
|
} else {
|
|
noteX = frontmatter.x || frontmatter.X;
|
|
noteY = frontmatter.y || frontmatter.Y;
|
|
}
|
|
|
|
if (noteX !== undefined && noteY !== undefined) {
|
|
const distance = calculateDistance(x, y, noteX, noteY);
|
|
|
|
matches.push({
|
|
path: filePath,
|
|
name: filePath.replace(/\.md$/, "").split("/").pop(),
|
|
frontmatter,
|
|
distance,
|
|
coordinates: {x: noteX, y: noteY}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
// Skip files that can't be read
|
|
DEBUG && console.debug("Skipping file:", filePath, error);
|
|
}
|
|
}
|
|
|
|
// Sort by distance and return top matches
|
|
matches.sort((a, b) => a.distance - b.distance);
|
|
const results = matches.slice(0, limit);
|
|
|
|
TIME && console.timeEnd("findNotesByCoordinates");
|
|
INFO && console.log(`Found ${results.length} nearby notes for (${x}, ${y})`);
|
|
|
|
return results;
|
|
} catch (error) {
|
|
ERROR && console.error("Failed to find notes by coordinates:", error);
|
|
TIME && console.timeEnd("findNotesByCoordinates");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Find note by FMG ID in frontmatter (with index for fast lookup)
|
|
async function findNoteByFmgId(fmgId) {
|
|
if (!fmgId) return null;
|
|
|
|
try {
|
|
// First, check the index for instant lookup
|
|
const indexedPath = getFromFmgIdIndex(fmgId);
|
|
if (indexedPath) {
|
|
INFO && console.log(`Found note in index: ${fmgId} → ${indexedPath}`);
|
|
try {
|
|
const content = await getNote(indexedPath);
|
|
const {frontmatter} = parseFrontmatter(content);
|
|
|
|
// Verify the fmg-id still matches (file might have been modified)
|
|
if (frontmatter["fmg-id"] === fmgId || frontmatter.fmgId === fmgId) {
|
|
return {
|
|
path: indexedPath,
|
|
name: indexedPath.replace(/\.md$/, "").split("/").pop(),
|
|
content,
|
|
frontmatter
|
|
};
|
|
} else {
|
|
// Index is stale, remove the entry
|
|
WARN && console.warn(`Index entry stale for ${fmgId}, removing`);
|
|
delete fmgIdIndex[fmgId];
|
|
saveFmgIdIndex();
|
|
}
|
|
} catch (error) {
|
|
// File no longer exists, remove from index
|
|
WARN && console.warn(`Indexed file not found: ${indexedPath}, removing from index`);
|
|
delete fmgIdIndex[fmgId];
|
|
saveFmgIdIndex();
|
|
}
|
|
}
|
|
|
|
// Not in index or index was stale, search all files
|
|
INFO && console.log(`Searching vault for fmg-id: ${fmgId}`);
|
|
const files = await getVaultFiles();
|
|
const mdFiles = files.filter(f => f.endsWith(".md"));
|
|
|
|
for (const filePath of mdFiles) {
|
|
try {
|
|
const content = await getNote(filePath);
|
|
const {frontmatter} = parseFrontmatter(content);
|
|
|
|
if (frontmatter["fmg-id"] === fmgId || frontmatter.fmgId === fmgId) {
|
|
// Found it! Add to index for next time
|
|
addToFmgIdIndex(fmgId, filePath);
|
|
INFO && console.log(`Found note and added to index: ${fmgId} → ${filePath}`);
|
|
|
|
return {
|
|
path: filePath,
|
|
name: filePath.replace(/\.md$/, "").split("/").pop(),
|
|
content,
|
|
frontmatter
|
|
};
|
|
}
|
|
} catch (error) {
|
|
DEBUG && console.debug("Skipping file:", filePath, error);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
ERROR && console.error("Failed to find note by FMG ID:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Generate note template for FMG element
|
|
function generateNoteTemplate(element, type, elementId) {
|
|
const {x, y} = element;
|
|
const lat = pack.cells.lat?.[element.cell] || 0;
|
|
const lon = pack.cells.lon?.[element.cell] || 0;
|
|
|
|
const frontmatter = {
|
|
"fmg-id": elementId || element.id || `${type}${element.i}`,
|
|
"fmg-type": type,
|
|
coordinates: {x, y, lat, lon},
|
|
tags: [type],
|
|
created: new Date().toISOString()
|
|
};
|
|
|
|
if (element.name) {
|
|
frontmatter.aliases = [element.name];
|
|
}
|
|
|
|
const yaml = Object.entries(frontmatter)
|
|
.map(([key, value]) => {
|
|
if (typeof value === "object" && !Array.isArray(value)) {
|
|
const nested = Object.entries(value)
|
|
.map(([k, v]) => ` ${k}: ${v}`)
|
|
.join("\n");
|
|
return `${key}:\n${nested}`;
|
|
} else if (Array.isArray(value)) {
|
|
return `${key}:\n - ${value.join("\n - ")}`;
|
|
}
|
|
return `${key}: ${value}`;
|
|
})
|
|
.join("\n");
|
|
|
|
const title = element.name || `${type} ${element.i}`;
|
|
|
|
return `---
|
|
${yaml}
|
|
---
|
|
|
|
# ${title}
|
|
|
|
*This note was created from Fantasy Map Generator*
|
|
|
|
## Description
|
|
|
|
Add your lore here...
|
|
|
|
## History
|
|
|
|
## Notable Features
|
|
|
|
## Related
|
|
`;
|
|
}
|
|
|
|
// Search notes by text query (searches in filename and frontmatter)
|
|
async function searchNotes(query) {
|
|
if (!query || query.trim() === "") {
|
|
return [];
|
|
}
|
|
|
|
const allFiles = await getVaultFiles();
|
|
const searchTerm = query.toLowerCase();
|
|
const results = [];
|
|
|
|
for (const filePath of allFiles) {
|
|
const fileName = filePath.split("/").pop().replace(".md", "").toLowerCase();
|
|
|
|
// Check if filename matches
|
|
if (fileName.includes(searchTerm)) {
|
|
try {
|
|
const content = await getNote(filePath);
|
|
const {frontmatter} = parseFrontmatter(content);
|
|
|
|
results.push({
|
|
path: filePath,
|
|
name: filePath.split("/").pop().replace(".md", ""),
|
|
frontmatter,
|
|
matchType: "filename"
|
|
});
|
|
} catch (error) {
|
|
WARN && console.warn(`Could not read file ${filePath}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// List all notes with basic info (reads frontmatter - slow for large vaults)
|
|
async function listAllNotes() {
|
|
const allFiles = await getVaultFiles();
|
|
const notes = [];
|
|
|
|
INFO && console.log(`listAllNotes: Processing ${allFiles.length} files`);
|
|
|
|
for (const filePath of allFiles) {
|
|
try {
|
|
const content = await getNote(filePath);
|
|
const {frontmatter} = parseFrontmatter(content);
|
|
|
|
notes.push({
|
|
path: filePath,
|
|
name: filePath.split("/").pop().replace(".md", ""),
|
|
frontmatter,
|
|
folder: filePath.includes("/") ? filePath.substring(0, filePath.lastIndexOf("/")) : ""
|
|
});
|
|
} catch (error) {
|
|
WARN && console.warn(`Could not read file ${filePath}:`, error);
|
|
}
|
|
}
|
|
|
|
// Sort by path
|
|
notes.sort((a, b) => a.path.localeCompare(b.path));
|
|
|
|
INFO && console.log(`listAllNotes: Returning ${notes.length} notes`);
|
|
DEBUG && console.log("Sample note paths:", notes.slice(0, 5).map(n => n.path));
|
|
|
|
return notes;
|
|
}
|
|
|
|
// List just file paths (fast - no content reading)
|
|
async function listAllNotePaths() {
|
|
const allFiles = await getVaultFiles();
|
|
|
|
INFO && console.log(`listAllNotePaths: Found ${allFiles.length} files`);
|
|
|
|
// Convert to note objects with just path and name
|
|
const notes = allFiles.map(filePath => ({
|
|
path: filePath,
|
|
name: filePath.split("/").pop().replace(".md", ""),
|
|
folder: filePath.includes("/") ? filePath.substring(0, filePath.lastIndexOf("/")) : ""
|
|
}));
|
|
|
|
// Sort by path
|
|
notes.sort((a, b) => a.path.localeCompare(b.path));
|
|
|
|
return notes;
|
|
}
|
|
|
|
return {
|
|
init,
|
|
config,
|
|
saveConfig,
|
|
testConnection,
|
|
getVaultFiles,
|
|
clearVaultCache,
|
|
getNote,
|
|
updateNote,
|
|
createNote,
|
|
parseFrontmatter,
|
|
findNotesByCoordinates,
|
|
findNoteByFmgId,
|
|
generateNoteTemplate,
|
|
searchNotes,
|
|
listAllNotes,
|
|
listAllNotePaths,
|
|
addToFmgIdIndex,
|
|
getFromFmgIdIndex
|
|
};
|
|
})();
|
|
|
|
// Initialize on load
|
|
ObsidianBridge.init();
|