Fantasy-Map-Generator/modules/io/obsidian-bridge.js
Claude f575631e30
feat(obsidian): add FMG ID index for instant note lookups
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.
2025-11-14 05:29:36 +00:00

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();