mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-17 17:51:24 +01:00
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:
parent
acc2d112f3
commit
769d3a31bb
6 changed files with 1226 additions and 2 deletions
73
modules/ui/obsidian-config.js
Normal file
73
modules/ui/obsidian-config.js
Normal 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);
|
||||
}
|
||||
358
modules/ui/obsidian-notes-editor.js
Normal file
358
modules/ui/obsidian-notes-editor.js
Normal 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";
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue