diff --git a/OBSIDIAN_INTEGRATION.md b/OBSIDIAN_INTEGRATION.md
new file mode 100644
index 00000000..5389cd03
--- /dev/null
+++ b/OBSIDIAN_INTEGRATION.md
@@ -0,0 +1,270 @@
+# Obsidian Vault Integration for Fantasy Map Generator
+
+## Overview
+
+Fantasy Map Generator now supports deep integration with your Obsidian vault for managing map lore and notes! This allows you to:
+
+- Store all your world lore in Markdown format
+- Edit notes in a modern Markdown editor (no more Win95-style TinyMCE!)
+- Automatically link map elements to Obsidian notes by coordinates
+- Keep your notes in sync between FMG and Obsidian
+- Use [[wikilinks]] to connect related notes
+- Edit in either FMG or Obsidian - changes sync both ways
+
+## Setup
+
+### 1. Install Obsidian Local REST API Plugin
+
+1. Open Obsidian
+2. Go to **Settings** → **Community Plugins**
+3. Click **Browse** and search for "Local REST API"
+4. Install and **Enable** the plugin
+5. Go to **Settings** → **Local REST API**
+6. Copy your **API Key** (you'll need this!)
+7. Note the **Server Port** (default: 27123)
+
+### 2. Configure in Fantasy Map Generator
+
+1. Open Fantasy Map Generator
+2. Go to **Menu** → **Tools** → **⚙ Obsidian**
+3. Enter your settings:
+ - **API URL**: `http://127.0.0.1:27123` (default)
+ - **API Key**: Paste from Obsidian plugin settings
+ - **Vault Name**: Name of your Obsidian vault
+4. Click **Test Connection** to verify
+5. Click **Save Configuration**
+
+## Usage
+
+### Linking Map Elements to Notes
+
+When you click on a **burg** or **marker** in FMG:
+
+1. FMG searches your Obsidian vault for notes with matching coordinates in YAML frontmatter
+2. Shows you the top 5-8 closest matches
+3. You select the note you want, or create a new one
+
+### Note Format
+
+Notes in your vault should have YAML frontmatter like this:
+
+```markdown
+---
+fmg-id: burg123
+fmg-type: burg
+coordinates:
+ x: 234.5
+ y: 456.7
+ lat: 45.23
+ lon: -73.45
+tags:
+ - capital
+ - settlement
+ - ancient
+aliases:
+ - Eldoria
+ - The Ancient City
+---
+
+# Eldoria
+
+The ancient capital sits upon the [[River Mystral]], founded in year 1203.
+
+## History
+
+The city was established by [[King Aldric the First]]...
+
+## Notable Locations
+
+- [[The Grand Library]]
+- [[Temple of the Seven Stars]]
+- [[Market Square]]
+```
+
+### Coordinate Matching
+
+Since your burgs/markers may have been imported from PostgreSQL without FMG IDs, the system matches by **X/Y coordinates**:
+
+- FMG extracts the coordinates from the clicked element
+- Searches all `.md` files in your vault for matching `x:` and `y:` values
+- Calculates distance and shows closest matches
+- You pick the right one!
+
+### Supported Coordinate Formats
+
+The system recognizes these formats in YAML frontmatter:
+
+```yaml
+# Nested object (recommended)
+coordinates:
+ x: 123.4
+ y: 567.8
+
+# Or flat
+x: 123.4
+y: 567.8
+
+# Case insensitive
+X: 123.4
+Y: 567.8
+```
+
+### Creating New Notes
+
+If no matches are found:
+
+1. FMG offers to create a new note
+2. Enter a name (e.g., "Eldoria")
+3. Optionally specify a folder (e.g., "Locations/Cities")
+4. FMG generates a template with coordinates
+5. Opens in the Markdown editor
+6. Saved directly to your Obsidian vault!
+
+### Editing Notes
+
+The modern Markdown editor includes:
+
+- **Live preview**: Toggle between edit/preview modes
+- **[[Wikilinks]]**: Link to other notes in your vault
+- **Syntax highlighting**: Clean monospace font
+- **Open in Obsidian**: Button to jump to the note in Obsidian app
+- **Save to Vault**: Changes sync immediately
+
+### Using Wikilinks
+
+Create connections between notes:
+
+```markdown
+The [[King Aldric the First]] ruled from [[Eldoria]].
+The city controls access to [[River Mystral]].
+```
+
+When you save in FMG, these links work in Obsidian!
+
+## Migration from PostgreSQL
+
+If you have existing lore in PostgreSQL with coordinates:
+
+1. Export your data to Markdown files with YAML frontmatter
+2. Include `x`, `y`, `lat`, `lon` in the frontmatter
+3. Place files in your Obsidian vault
+4. FMG will auto-match by coordinates!
+
+Example export script template:
+
+```python
+for location in locations:
+ frontmatter = f"""---
+fmg-type: {location.type}
+coordinates:
+ x: {location.x}
+ y: {location.y}
+ lat: {location.lat}
+ lon: {location.lon}
+tags: {location.tags}
+---
+
+# {location.name}
+
+{location.description}
+"""
+ with open(f"vault/{location.name}.md", "w") as f:
+ f.write(frontmatter)
+```
+
+## Tips & Tricks
+
+### Organize Your Vault
+
+Create folders for different types:
+
+```
+My Vault/
+├── Locations/
+│ ├── Cities/
+│ ├── Landmarks/
+│ └── Regions/
+├── Characters/
+├── History/
+└── Lore/
+```
+
+### Use Templates
+
+Create Obsidian templates for different element types:
+
+- `Templates/City.md`
+- `Templates/Landmark.md`
+- `Templates/Character.md`
+
+### Search and Graph
+
+In Obsidian:
+
+- Use **Search** (`Ctrl+Shift+F`) to find notes by coordinates
+- Use **Graph View** to see connections between locations
+- Use **Tags** to organize by type
+
+### Sync Across Devices
+
+Use Obsidian Sync or Git to keep your vault synced across computers!
+
+## Troubleshooting
+
+### Connection Failed
+
+- Make sure Obsidian is running
+- Verify the Local REST API plugin is enabled
+- Check the port number (default 27123)
+- Try restarting Obsidian
+
+### No Matches Found
+
+- Check that your notes have `x:` and `y:` fields in frontmatter
+- Verify coordinates are numbers, not strings
+- Try increasing the search radius
+
+### Changes Not Appearing in Obsidian
+
+- Obsidian should auto-detect file changes
+- If not, try switching to another note and back
+- Or close/reopen the note
+
+## Advanced
+
+### Custom Coordinate Systems
+
+If you use a different coordinate system:
+
+1. Map your coordinates to FMG's system
+2. Store both in frontmatter:
+```yaml
+coordinates:
+ x: 234.5 # FMG coordinates
+ y: 456.7
+ custom_x: 1000 # Your system
+ custom_y: 2000
+```
+
+### Database Bridge
+
+For the future PostgreSQL migration:
+
+1. Keep coordinates in both Obsidian and database
+2. Use coordinates as the join key
+3. Sync changes via API
+4. Eventually replace file storage with DB
+
+## Future Features
+
+Planned enhancements:
+
+- [ ] Time slider - view notes across historical periods
+- [ ] Automatic tagging by region/culture
+- [ ] Bulk import from database
+- [ ] Real-time collaboration
+- [ ] Custom Markdown extensions
+
+---
+
+**Enjoy your modern, Markdown-powered world-building! 🗺️✨**
diff --git a/index.html b/index.html
index 9175879e..b644322f 100644
--- a/index.html
+++ b/index.html
@@ -2062,6 +2062,9 @@
Namesbase
+
@@ -4963,6 +4966,107 @@
+
+
+
+
+
+ 📁
+
+
+
+
+
+
+
+
+
+
+
+ Markdown
+
+
+
+
+
+
+
+
+
+ 💡 Tip: Use [[wikilinks]] to link to other notes
+
+
+
+
+
+
+
+
+
+
+
+
Configure connection to your Obsidian vault via the Local REST API plugin.
+
+
+
+
+ Default: http://127.0.0.1:27123
+
+
+
+
+
+ Get this from Obsidian → Settings → Local REST API
+
+
+
+
+
+ Name of your Obsidian vault (for opening links)
+
+
+
+ Status:Not configured
+
+
+
+
+
+
+
+
+
+
@@ -8173,10 +8277,13 @@
-
+
+
+
diff --git a/modules/io/obsidian-bridge.js b/modules/io/obsidian-bridge.js
new file mode 100644
index 00000000..1d8325fc
--- /dev/null
+++ b/modules/io/obsidian-bridge.js
@@ -0,0 +1,416 @@
+"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: ""
+ };
+
+ // 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);
+ }
+ }
+ }
+
+ // 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;
+ }
+ }
+
+ // Get all markdown files from vault
+ async function getVaultFiles() {
+ if (!config.enabled) {
+ throw new Error("Obsidian not connected");
+ }
+
+ try {
+ const response = await fetch(`${config.apiUrl}/vault/`, {
+ headers: {
+ Authorization: `Bearer ${config.apiKey}`
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch vault files: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return data.files || [];
+ } catch (error) {
+ ERROR && console.error("Failed to get vault files:", error);
+ 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
+ async function findNoteByFmgId(fmgId) {
+ try {
+ 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) {
+ 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) {
+ const {x, y} = element;
+ const lat = pack.cells.lat?.[element.cell] || 0;
+ const lon = pack.cells.lon?.[element.cell] || 0;
+
+ const frontmatter = {
+ "fmg-id": 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
+`;
+ }
+
+ return {
+ init,
+ config,
+ saveConfig,
+ testConnection,
+ getVaultFiles,
+ getNote,
+ updateNote,
+ createNote,
+ parseFrontmatter,
+ findNotesByCoordinates,
+ findNoteByFmgId,
+ generateNoteTemplate
+ };
+})();
+
+// Initialize on load
+ObsidianBridge.init();
diff --git a/modules/ui/obsidian-config.js b/modules/ui/obsidian-config.js
new file mode 100644
index 00000000..f5db2ae1
--- /dev/null
+++ b/modules/ui/obsidian-config.js
@@ -0,0 +1,73 @@
+"use strict";
+
+// Obsidian Configuration UI
+
+function openObsidianConfig() {
+ // Load current config
+ const {apiUrl, apiKey, vaultName, enabled} = ObsidianBridge.config;
+
+ byId("obsidianApiUrl").value = apiUrl || "http://127.0.0.1:27123";
+ byId("obsidianApiKey").value = apiKey || "";
+ byId("obsidianVaultName").value = vaultName || "";
+
+ updateObsidianStatus(enabled);
+
+ $("#obsidianConfig").dialog({
+ title: "Obsidian Vault Configuration",
+ width: "600px",
+ position: {my: "center", at: "center", of: "svg"}
+ });
+}
+
+function updateObsidianStatus(enabled) {
+ const statusEl = byId("obsidianStatus");
+ if (enabled) {
+ statusEl.textContent = "✅ Connected";
+ statusEl.style.color = "#2ecc71";
+ } else {
+ statusEl.textContent = "❌ Not connected";
+ statusEl.style.color = "#e74c3c";
+ }
+}
+
+async function testObsidianConnection() {
+ const apiUrl = byId("obsidianApiUrl").value.trim();
+ const apiKey = byId("obsidianApiKey").value.trim();
+
+ if (!apiUrl || !apiKey) {
+ tip("Please enter both API URL and API Key", false, "error", 3000);
+ return;
+ }
+
+ // Temporarily set config for testing
+ Object.assign(ObsidianBridge.config, {apiUrl, apiKey});
+
+ byId("obsidianStatus").textContent = "Testing connection...";
+ byId("obsidianStatus").style.color = "#f39c12";
+
+ try {
+ await ObsidianBridge.testConnection();
+ updateObsidianStatus(true);
+ tip("Successfully connected to Obsidian!", true, "success", 3000);
+ } catch (error) {
+ updateObsidianStatus(false);
+ tip("Connection failed: " + error.message, true, "error", 5000);
+ }
+}
+
+function saveObsidianConfig() {
+ const apiUrl = byId("obsidianApiUrl").value.trim();
+ const apiKey = byId("obsidianApiKey").value.trim();
+ const vaultName = byId("obsidianVaultName").value.trim();
+
+ if (!apiUrl || !apiKey) {
+ tip("Please enter both API URL and API Key", false, "error", 3000);
+ return;
+ }
+
+ Object.assign(ObsidianBridge.config, {apiUrl, apiKey, vaultName});
+ ObsidianBridge.saveConfig();
+
+ $("#obsidianConfig").dialog("close");
+ tip("Obsidian configuration saved", true, "success", 2000);
+}
diff --git a/modules/ui/obsidian-notes-editor.js b/modules/ui/obsidian-notes-editor.js
new file mode 100644
index 00000000..78fa133e
--- /dev/null
+++ b/modules/ui/obsidian-notes-editor.js
@@ -0,0 +1,358 @@
+"use strict";
+
+// Modern Markdown Notes Editor with Obsidian Integration
+
+function editObsidianNote(elementId, elementType, coordinates) {
+ const {x, y} = coordinates;
+
+ // Show loading dialog
+ showLoadingDialog();
+
+ // Try to find note by FMG ID first, then by coordinates
+ findOrCreateNote(elementId, elementType, coordinates)
+ .then(noteData => {
+ showMarkdownEditor(noteData, elementType);
+ })
+ .catch(error => {
+ ERROR && console.error("Failed to load note:", error);
+ tip("Failed to load Obsidian note: " + error.message, true, "error", 5000);
+ closeDialogs("#obsidianNoteLoading");
+ });
+}
+
+async function findOrCreateNote(elementId, elementType, coordinates) {
+ const {x, y} = coordinates;
+
+ // First try to find by FMG ID
+ let note = await ObsidianBridge.findNoteByFmgId(elementId);
+
+ if (note) {
+ INFO && console.log("Found note by FMG ID:", note.path);
+ return note;
+ }
+
+ // Find by coordinates
+ const matches = await ObsidianBridge.findNotesByCoordinates(x, y, 8);
+
+ closeDialogs("#obsidianNoteLoading");
+
+ if (matches.length === 0) {
+ // No matches - offer to create new note
+ return await promptCreateNewNote(elementId, elementType, coordinates);
+ }
+
+ if (matches.length === 1) {
+ // Single match - load it
+ const match = matches[0];
+ const content = await ObsidianBridge.getNote(match.path);
+ return {
+ path: match.path,
+ name: match.name,
+ content,
+ frontmatter: match.frontmatter
+ };
+ }
+
+ // Multiple matches - show selection dialog
+ return await showNoteSelectionDialog(matches, elementId, elementType, coordinates);
+}
+
+function showLoadingDialog() {
+ alertMessage.innerHTML = `
+