Merge pull request #1 from n8k99/claude/claude-md-mhy85sj7tlvzwb5w-01QzBpdgGJXE5Qk3JaNupuxM

Claude/claude md mhy85sj7tlvzwb5w 01 qz bpdg gjxe5 qk3 ja nupux m
This commit is contained in:
Nathan Eckenrode 2025-11-13 22:04:09 -05:00 committed by GitHub
commit fe15bd0cf0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2211 additions and 7 deletions

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

@ -167,6 +167,32 @@ async function saveToStorage(mapData, showTip = false) {
showTip && tip("Map is saved to the browser storage", false, "success");
}
// save current map as the default map
async function saveAsDefaultMap() {
if (customization) return tip("Map cannot be saved in EDIT mode, please complete the edit and retry", false, "error");
try {
const mapData = prepareMapData();
const blob = new Blob([mapData], {type: "text/plain"});
await ldb.set("defaultMap", blob);
tip("Map is set as default and will open on load", true, "success", 5000);
} catch (error) {
ERROR && console.error(error);
tip("Failed to set default map", true, "error", 3000);
}
}
// clear the default map setting
async function clearDefaultMap() {
try {
await ldb.set("defaultMap", null);
tip("Default map cleared", false, "success", 2000);
} catch (error) {
ERROR && console.error(error);
tip("Failed to clear default map", false, "error", 2000);
}
}
// download map file
function saveToMachine(mapData, filename) {
const blob = new Blob([mapData], {type: "text/plain"});

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

@ -121,11 +121,16 @@ function editUnits() {
function addRuler() {
if (!layerIsOn("toggleRulers")) toggleRulers();
const width = Math.min(graphWidth, svgWidth);
const height = Math.min(graphHeight, svgHeight);
const pt = byId("map").createSVGPoint();
(pt.x = graphWidth / 2), (pt.y = graphHeight / 4);
pt.x = width / 2;
pt.y = height / 4;
const p = pt.matrixTransform(viewbox.node().getScreenCTM().inverse());
const dx = graphWidth / 4 / scale;
const dy = (rulers.data.length * 40) % (graphHeight / 2);
const dx = width / 4 / scale;
const dy = (rulers.data.length * 40) % (height / 2);
const from = [(p.x - dx) | 0, (p.y + dy) | 0];
const to = [(p.x + dx) | 0, (p.y + dy) | 0];
rulers.create(Ruler, [from, to]).draw();