feat: add Obsidian vault integration for modern Markdown notes

Add comprehensive Obsidian integration as intermediate step toward
PostgreSQL migration, enabling modern Markdown-based note editing:

**Features:**
- Obsidian Local REST API integration for vault access
- Coordinate-based note matching (searches vault YAML frontmatter)
- Shows top 5-8 closest matches when clicking burgs/markers
- Modern Markdown editor with live preview
- [[Wikilink]] support for connecting notes
- "Open in Obsidian" button to jump to native app
- Configuration UI for API setup and testing

**Technical Implementation:**
- modules/io/obsidian-bridge.js - Core API integration layer
- modules/ui/obsidian-notes-editor.js - Markdown editor UI
- modules/ui/obsidian-config.js - Configuration panel
- OBSIDIAN_INTEGRATION.md - Complete setup/usage guide

**Coordinate Matching:**
- Parses YAML frontmatter for x/y coordinates
- Calculates distance to clicked element
- Supports nested (coordinates.x) and flat (x:) formats
- Handles missing FMG IDs (common with PostgreSQL imports)

**User Workflow:**
1. Configure Obsidian REST API connection
2. Click burg/marker in FMG
3. System finds matching notes by coordinates
4. Select note or create new one
5. Edit in modern Markdown editor
6. Save syncs to Obsidian vault instantly

This replaces the "Win95-style TinyMCE" editor with a clean,
modern Markdown experience while maintaining compatibility with
the eventual PostgreSQL backend migration. Users can edit notes
in either FMG or Obsidian - both stay in sync via file system.

Version: 1.108.13
This commit is contained in:
Claude 2025-11-14 02:57:07 +00:00
parent acc2d112f3
commit 769d3a31bb
No known key found for this signature in database
6 changed files with 1226 additions and 2 deletions

270
OBSIDIAN_INTEGRATION.md Normal file
View file

@ -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! 🗺️✨**

View file

@ -2062,6 +2062,9 @@
Namesbase
</button>
<button id="editNotesButton" data-tip="Click to open Notes Editor" data-shortcut="Shift + O">Notes</button>
<button id="obsidianConfigButton" data-tip="Configure Obsidian vault integration for modern Markdown notes" onclick="openObsidianConfig()">
⚙ Obsidian
</button>
<button id="editProvincesButton" data-tip="Click to open Provinces Editor" data-shortcut="Shift + P">
Provinces
</button>
@ -4963,6 +4966,107 @@
</div>
</div>
<!-- Obsidian Notes Editor (Modern Markdown) -->
<div id="obsidianNotesEditor" class="dialog stable" style="display: none">
<div style="margin-bottom: 0.8em; padding-bottom: 0.8em; border-bottom: 1px solid #ddd">
<div style="display: flex; align-items: center; gap: 1em; margin-bottom: 0.5em;">
<div style="flex: 1;">
<span style="font-size: 0.9em; color: #666;">📁 </span>
<span id="obsidianNotePath" style="font-size: 0.9em; color: #666;"></span>
</div>
<button id="openInObsidian" onclick="openInObsidian()" data-tip="Open this note in Obsidian app" style="padding: 4px 12px;">
Open in Obsidian
</button>
</div>
<input id="obsidianNoteName" type="text" placeholder="Note name" style="width: 100%; padding: 8px; font-size: 1.1em; font-weight: bold; border: 1px solid #ddd; border-radius: 4px;"/>
</div>
<div style="display: flex; gap: 12px; height: calc(100% - 120px);">
<!-- Editor pane -->
<div style="flex: 1; display: flex; flex-direction: column;">
<div style="margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
<strong style="color: #666;">Markdown</strong>
<button id="togglePreview" onclick="togglePreviewMode()" style="padding: 4px 12px;">👁 Preview</button>
</div>
<textarea
id="obsidianMarkdownEditor"
oninput="updateMarkdownPreview()"
style="flex: 1; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace; font-size: 14px; padding: 12px; border: 1px solid #ddd; border-radius: 4px; resize: none;"
placeholder="Write your markdown here..."
></textarea>
<div
id="obsidianMarkdownPreview"
style="display: none; flex: 1; padding: 12px; border: 1px solid #ddd; border-radius: 4px; overflow-y: auto; background: #fafafa;"
></div>
</div>
</div>
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #ddd; display: flex; justify-content: space-between;">
<div>
<span style="font-size: 0.9em; color: #666;">💡 Tip: Use [[wikilinks]] to link to other notes</span>
</div>
<div style="display: flex; gap: 8px;">
<button onclick="$('#obsidianNotesEditor').dialog('close')" style="padding: 6px 16px;">Cancel</button>
<button onclick="saveObsidianNote()" style="padding: 6px 16px; background: #5e81ac; color: white; border: none; border-radius: 4px; cursor: pointer;">
💾 Save to Vault
</button>
</div>
</div>
</div>
<!-- Obsidian Configuration Dialog -->
<div id="obsidianConfig" class="dialog stable" style="display: none">
<div style="padding: 1em;">
<p style="margin-bottom: 1em;">Configure connection to your Obsidian vault via the Local REST API plugin.</p>
<div style="margin-bottom: 1em;">
<label for="obsidianApiUrl" style="display: block; margin-bottom: 0.5em; font-weight: bold;">API URL:</label>
<input
id="obsidianApiUrl"
type="text"
value="http://127.0.0.1:27123"
style="width: 100%; padding: 8px; font-family: monospace;"
placeholder="http://127.0.0.1:27123"
/>
<small style="color: #666;">Default: http://127.0.0.1:27123</small>
</div>
<div style="margin-bottom: 1em;">
<label for="obsidianApiKey" style="display: block; margin-bottom: 0.5em; font-weight: bold;">API Key:</label>
<input
id="obsidianApiKey"
type="password"
style="width: 100%; padding: 8px; font-family: monospace;"
placeholder="Enter your API key from Obsidian plugin settings"
/>
<small style="color: #666;">Get this from Obsidian → Settings → Local REST API</small>
</div>
<div style="margin-bottom: 1em;">
<label for="obsidianVaultName" style="display: block; margin-bottom: 0.5em; font-weight: bold;">Vault Name:</label>
<input
id="obsidianVaultName"
type="text"
style="width: 100%; padding: 8px;"
placeholder="My Vault"
/>
<small style="color: #666;">Name of your Obsidian vault (for opening links)</small>
</div>
<div style="margin: 1.5em 0; padding: 1em; background: #f0f0f0; border-radius: 4px;">
<strong>Status:</strong> <span id="obsidianStatus" style="color: #666;">Not configured</span>
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button onclick="testObsidianConnection()" style="padding: 8px 16px;">Test Connection</button>
<button onclick="$('#obsidianConfig').dialog('close')" style="padding: 8px 16px;">Cancel</button>
<button onclick="saveObsidianConfig()" style="padding: 8px 16px; background: #5e81ac; color: white; border: none; border-radius: 4px;">
Save Configuration
</button>
</div>
</div>
</div>
<div id="aiGenerator" class="dialog stable" style="display: none">
<div style="display: flex; flex-direction: column; gap: 0.3em; width: 100%">
<textarea id="aiGeneratorResult" placeholder="Generated text will appear here" cols="30" rows="10"></textarea>
@ -8173,10 +8277,13 @@
<script defer src="modules/coa-renderer.js?v=1.99.00"></script>
<script defer src="libs/rgbquant.min.js"></script>
<script defer src="libs/jquery.ui.touch-punch.min.js"></script>
<script defer src="modules/io/save.js?v=1.108.12">
<script defer src="modules/io/save.js?v=1.108.12"></script>
<script defer src="modules/io/load.js?v=1.108.0"></script>
<script defer src="modules/io/cloud.js?v=1.106.0"></script>
<script defer src="modules/io/export.js?v=1.108.11"></script>
<script defer src="modules/io/obsidian-bridge.js?v=1.108.13"></script>
<script defer src="modules/ui/obsidian-notes-editor.js?v=1.108.13"></script>
<script defer src="modules/ui/obsidian-config.js?v=1.108.13"></script>
<script defer src="modules/renderers/draw-features.js?v=1.108.2"></script>
<script defer src="modules/renderers/draw-borders.js?v=1.104.0"></script>

View file

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

View file

@ -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);
}

View file

@ -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 = `
<div style="text-align: center; padding: 2em;">
<div class="spinner" style="margin: 0 auto 1em;"></div>
<p>Searching Obsidian vault for matching notes...</p>
</div>
`;
$("#alert").dialog({
title: "Loading from Obsidian",
width: "400px",
closeOnEscape: false,
buttons: {},
dialogClass: "no-close",
position: {my: "center", at: "center", of: "svg"}
});
}
async function showNoteSelectionDialog(matches, elementId, elementType, coordinates) {
return new Promise((resolve, reject) => {
const matchList = matches
.map(
(match, index) => `
<div class="note-match" data-index="${index}" style="
padding: 12px;
margin: 8px 0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
" onmouseover="this.style.background='#f0f0f0'" onmouseout="this.style.background='white'">
<div style="font-weight: bold; margin-bottom: 4px;">${match.name}</div>
<div style="font-size: 0.9em; color: #666;">
Distance: ${match.distance.toFixed(1)} units<br/>
Coordinates: (${match.coordinates.x}, ${match.coordinates.y})<br/>
Path: ${match.path}
</div>
</div>
`
)
.join("");
alertMessage.innerHTML = `
<div style="max-height: 60vh; overflow-y: auto;">
<p style="margin-bottom: 1em;">Found ${matches.length} notes near this location. Select one:</p>
${matchList}
</div>
`;
$("#alert").dialog({
title: "Select Obsidian Note",
width: "600px",
buttons: {
"Create New": async function () {
$(this).dialog("close");
try {
const newNote = await promptCreateNewNote(elementId, elementType, coordinates);
resolve(newNote);
} catch (error) {
reject(error);
}
},
Cancel: function () {
$(this).dialog("close");
reject(new Error("Cancelled"));
}
},
position: {my: "center", at: "center", of: "svg"}
});
// Add click handlers to matches
document.querySelectorAll(".note-match").forEach((el, index) => {
el.addEventListener("click", async () => {
$("#alert").dialog("close");
try {
const match = matches[index];
const content = await ObsidianBridge.getNote(match.path);
resolve({
path: match.path,
name: match.name,
content,
frontmatter: match.frontmatter
});
} catch (error) {
reject(error);
}
});
});
});
}
async function promptCreateNewNote(elementId, elementType, coordinates) {
return new Promise((resolve, reject) => {
const element = getElementData(elementId, elementType);
const suggestedName = element.name || `${elementType}-${element.i}`;
alertMessage.innerHTML = `
<p>No matching notes found. Create a new note in your Obsidian vault?</p>
<div style="margin: 1em 0;">
<label for="newNoteName" style="display: block; margin-bottom: 0.5em;">Note name:</label>
<input id="newNoteName" type="text" value="${suggestedName}" style="width: 100%; padding: 8px; font-size: 1em;"/>
</div>
<div style="margin: 1em 0;">
<label for="newNotePath" style="display: block; margin-bottom: 0.5em;">Folder (optional):</label>
<input id="newNotePath" type="text" placeholder="e.g., Locations/Cities" style="width: 100%; padding: 8px; font-size: 1em;"/>
</div>
`;
$("#alert").dialog({
title: "Create New Note",
width: "500px",
buttons: {
Create: async function () {
const name = byId("newNoteName").value.trim();
const folder = byId("newNotePath").value.trim();
if (!name) {
tip("Please enter a note name", false, "error");
return;
}
const notePath = folder ? `${folder}/${name}.md` : `${name}.md`;
$(this).dialog("close");
try {
const template = ObsidianBridge.generateNoteTemplate(element, elementType);
await ObsidianBridge.createNote(notePath, template);
const {frontmatter} = ObsidianBridge.parseFrontmatter(template);
resolve({
path: notePath,
name,
content: template,
frontmatter,
isNew: true
});
} catch (error) {
reject(error);
}
},
Cancel: function () {
$(this).dialog("close");
reject(new Error("Cancelled"));
}
},
position: {my: "center", at: "center", of: "svg"}
});
});
}
function getElementData(elementId, elementType) {
// Extract element data based on type
if (elementType === "burg") {
const burgId = parseInt(elementId.replace("burg", ""));
return pack.burgs[burgId];
} else if (elementType === "marker") {
const markerId = parseInt(elementId.replace("marker", ""));
return pack.markers[markerId];
} else {
// Generic element
const el = document.getElementById(elementId);
return {
id: elementId,
name: elementId,
x: parseFloat(el?.getAttribute("cx") || 0),
y: parseFloat(el?.getAttribute("cy") || 0)
};
}
}
function showMarkdownEditor(noteData, elementType) {
const {path, name, content, frontmatter, isNew} = noteData;
// Extract frontmatter and body
const {content: bodyContent} = ObsidianBridge.parseFrontmatter(content);
// Set up the dialog
byId("obsidianNotePath").textContent = path;
byId("obsidianNoteName").value = name;
byId("obsidianMarkdownEditor").value = content;
byId("obsidianMarkdownPreview").innerHTML = renderMarkdown(bodyContent);
// Store current note data
showMarkdownEditor.currentNote = noteData;
showMarkdownEditor.originalContent = content;
$("#obsidianNotesEditor").dialog({
title: `Obsidian Note: ${name}`,
width: Math.min(svgWidth * 0.9, 1200),
height: svgHeight * 0.85,
position: {my: "center", at: "center", of: "svg"},
close: () => {
showMarkdownEditor.currentNote = null;
showMarkdownEditor.originalContent = null;
}
});
// Update preview on edit
updateMarkdownPreview();
if (isNew) {
tip("New note created in Obsidian vault", true, "success", 3000);
}
}
function updateMarkdownPreview() {
const content = byId("obsidianMarkdownEditor").value;
const {content: bodyContent} = ObsidianBridge.parseFrontmatter(content);
byId("obsidianMarkdownPreview").innerHTML = renderMarkdown(bodyContent);
}
function renderMarkdown(markdown) {
// Simple Markdown renderer (will be replaced with marked.js)
let html = markdown;
// Headers
html = html.replace(/^### (.*$)/gim, "<h3>$1</h3>");
html = html.replace(/^## (.*$)/gim, "<h2>$1</h2>");
html = html.replace(/^# (.*$)/gim, "<h1>$1</h1>");
// Bold
html = html.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\_\_(.*?)\_\_/g, "<strong>$1</strong>");
// Italic
html = html.replace(/\*(.*?)\*/g, "<em>$1</em>");
html = html.replace(/\_(.*?)\_/g, "<em>$1</em>");
// Links
html = html.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
// Wikilinks [[Page]]
html = html.replace(/\[\[([^\]]+)\]\]/g, '<span class="wikilink">$1</span>');
// Lists
html = html.replace(/^\* (.*)$/gim, "<li>$1</li>");
html = html.replace(/^\- (.*)$/gim, "<li>$1</li>");
html = html.replace(/(<li>.*<\/li>)/s, "<ul>$1</ul>");
// Paragraphs
html = html.replace(/\n\n/g, "</p><p>");
html = "<p>" + html + "</p>";
// Clean up
html = html.replace(/<p><\/p>/g, "");
html = html.replace(/<p>(<h[1-6]>)/g, "$1");
html = html.replace(/(<\/h[1-6]>)<\/p>/g, "$1");
return html;
}
async function saveObsidianNote() {
if (!showMarkdownEditor.currentNote) {
tip("No note loaded", false, "error");
return;
}
const content = byId("obsidianMarkdownEditor").value;
const {path} = showMarkdownEditor.currentNote;
try {
await ObsidianBridge.updateNote(path, content);
showMarkdownEditor.originalContent = content;
tip("Note saved to Obsidian vault", true, "success", 2000);
} catch (error) {
ERROR && console.error("Failed to save note:", error);
tip("Failed to save note: " + error.message, true, "error", 5000);
}
}
function openInObsidian() {
if (!showMarkdownEditor.currentNote) return;
const {path} = showMarkdownEditor.currentNote;
const vaultName = ObsidianBridge.config.vaultName || "vault";
const obsidianUrl = `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(path)}`;
window.open(obsidianUrl, "_blank");
tip("Opening in Obsidian app...", false, "success", 2000);
}
function togglePreviewMode() {
const editor = byId("obsidianMarkdownEditor");
const preview = byId("obsidianMarkdownPreview");
const isPreviewMode = editor.style.display === "none";
if (isPreviewMode) {
editor.style.display = "block";
preview.style.display = "none";
byId("togglePreview").textContent = "👁 Preview";
} else {
updateMarkdownPreview();
editor.style.display = "none";
preview.style.display = "block";
byId("togglePreview").textContent = "✏ Edit";
}
}

View file

@ -13,7 +13,7 @@
* Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2
*/
const VERSION = "1.108.12";
const VERSION = "1.108.13";
if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function");
{