mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-16 17:31:24 +01:00
Merge ace6867259 into f73a8906ce
This commit is contained in:
commit
d047d758a1
17 changed files with 2966 additions and 40 deletions
832
CLAUDE.md
Normal file
832
CLAUDE.md
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
# CLAUDE.md - AI Assistant Guide for Fantasy Map Generator
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Fantasy Map Generator** (FMG) is a free web-based application for creating and editing fantasy maps. It's a massive client-side JavaScript application designed for fantasy writers, game masters, and cartographers.
|
||||
|
||||
- **Repository**: https://github.com/Azgaar/Fantasy-Map-Generator
|
||||
- **Live App**: https://azgaar.github.io/Fantasy-Map-Generator
|
||||
- **Language**: Pure JavaScript (ES6+)
|
||||
- **License**: MIT
|
||||
- **Primary Author**: Azgaar (azgaar.fmg@yandex.com)
|
||||
|
||||
## Architecture & Technology Stack
|
||||
|
||||
### Core Technologies
|
||||
|
||||
- **Pure JavaScript (ES6+)**: NO build system, transpilation, or bundling
|
||||
- **D3.js v7**: SVG manipulation, data visualization, zoom/pan interactions
|
||||
- **jQuery 3.1.1 + jQuery UI**: DOM manipulation, dialogs, UI components
|
||||
- **Progressive Web App (PWA)**: Service worker caching, offline support, IndexedDB storage
|
||||
|
||||
### Key Libraries (`/libs/`)
|
||||
|
||||
```
|
||||
d3.min.js # Main visualization library
|
||||
delaunator.min.js # Delaunay triangulation for Voronoi mesh
|
||||
alea.min.js # Seedable random number generator
|
||||
three.min.js # 3D rendering support
|
||||
jszip.min.js # ZIP file creation for exports
|
||||
polylabel.min.js # Label placement optimization
|
||||
tinymce/ # Rich text editor for notes
|
||||
```
|
||||
|
||||
### Architecture Pattern
|
||||
|
||||
**Global Object Pattern**: No module bundler, everything attached to `window` object:
|
||||
|
||||
```javascript
|
||||
// Global data structures (from main.js:151-158)
|
||||
let grid = {}; // Initial Voronoi graph
|
||||
let pack = {}; // Main packed data structure
|
||||
let seed; // Map generation seed
|
||||
let mapId; // Unique map identifier
|
||||
let mapHistory = []; // Undo/redo history
|
||||
let modules = {}; // Module initialization flags
|
||||
let notes = []; // User notes
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
Fantasy-Map-Generator/
|
||||
├── index.html # 8,184-line monolithic HTML (entire UI)
|
||||
├── main.js # 1,288 lines - initialization, globals, SVG setup
|
||||
├── versioning.js # Version management (currently v1.108.11)
|
||||
├── sw.js # Service worker for PWA caching
|
||||
│
|
||||
├── modules/ # Core application logic (232 JS files)
|
||||
│ ├── dynamic/ # Dynamically imported modules
|
||||
│ │ ├── editors/ # Advanced editors (states, religions, cultures)
|
||||
│ │ ├── overview/ # Data visualization tools
|
||||
│ │ └── *.js # Auto-update, installation, hierarchy-tree
|
||||
│ ├── io/ # Input/Output operations
|
||||
│ │ ├── cloud.js # Cloud storage (Dropbox)
|
||||
│ │ ├── export.js # Map export functionality
|
||||
│ │ ├── load.js # Map loading
|
||||
│ │ └── save.js # Map saving
|
||||
│ ├── renderers/ # SVG rendering (12 files)
|
||||
│ │ ├── draw-borders.js
|
||||
│ │ ├── draw-heightmap.js
|
||||
│ │ ├── draw-markers.js
|
||||
│ │ └── ...
|
||||
│ └── ui/ # UI editors and tools (~35 files)
|
||||
│ ├── editors.js # Common editor functions
|
||||
│ ├── options.js # Map configuration
|
||||
│ └── ...
|
||||
│
|
||||
├── utils/ # Utility functions (15 files)
|
||||
│ ├── arrayUtils.js
|
||||
│ ├── colorUtils.js
|
||||
│ ├── commonUtils.js # debounce, throttle
|
||||
│ ├── graphUtils.js # Graph algorithms (297 lines)
|
||||
│ ├── pathUtils.js # SVG path operations (222 lines)
|
||||
│ └── ...
|
||||
│
|
||||
├── charges/ # 400+ SVG heraldic symbols
|
||||
├── components/ # Reusable UI components
|
||||
├── config/ # Heightmap template configurations
|
||||
├── heightmaps/ # Precreated heightmap resources
|
||||
├── images/ # Application images/icons
|
||||
├── styles/ # 12 JSON theme presets (default, night, etc.)
|
||||
│
|
||||
└── .github/
|
||||
├── pull_request_template.md
|
||||
└── ISSUE_TEMPLATE/
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
### Entry Points
|
||||
|
||||
- **index.html** (line 1-8184): Entire UI structure, loads all scripts
|
||||
- **main.js** (line 1-1288): Initializes globals, SVG layers, data structures
|
||||
- **versioning.js**: Current version: `1.108.11` (semantic versioning)
|
||||
|
||||
### Core Generators
|
||||
|
||||
```javascript
|
||||
modules/burgs-and-states.js # City/state generation (1,018 lines)
|
||||
modules/cultures-generator.js # Culture system (1,039 lines)
|
||||
modules/names-generator.js # Name generation (3,371 lines)
|
||||
modules/heightmap-generator.js # Terrain generation (445 lines)
|
||||
modules/river-generator.js # River systems (507 lines)
|
||||
modules/routes-generator.js # Road/trail generation (537 lines)
|
||||
modules/religions-generator.js # Religion system (757 lines)
|
||||
modules/military-generator.js # Military units (405 lines)
|
||||
modules/markers-generator.js # Map markers (1,163 lines)
|
||||
modules/coa-generator.js # Coat of arms logic (1,015 lines)
|
||||
modules/coa-renderer.js # COA SVG rendering (2,527 lines)
|
||||
modules/provinces-generator.js # Province subdivision (302 lines)
|
||||
modules/zones-generator.js # Zone assignment (430 lines)
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
### Primary Data Structure: `pack` Object
|
||||
|
||||
The `pack` object is the heart of FMG's data model. It contains the entire map state using **typed arrays** for performance:
|
||||
|
||||
```javascript
|
||||
pack = {
|
||||
cells: {
|
||||
i: Uint32Array, // Cell indices
|
||||
v: Array, // Adjacent vertices
|
||||
c: Array, // Adjacent cells
|
||||
b: Uint8Array, // Border flags
|
||||
h: Uint8Array, // Height (0-100)
|
||||
temp: Int8Array, // Temperature
|
||||
prec: Uint8Array, // Precipitation
|
||||
f: Uint16Array, // Feature (biome) ID
|
||||
t: Int8Array, // Terrain type
|
||||
haven: Uint16Array, // Harbor ID
|
||||
harbor: Uint8Array, // Harbor presence
|
||||
fl: Uint16Array, // Flux (river flow)
|
||||
r: Uint16Array, // River ID
|
||||
conf: Uint8Array, // River confluence
|
||||
pop: Float32Array, // Population density
|
||||
culture: Uint16Array, // Culture ID
|
||||
burg: Uint16Array, // Settlement ID
|
||||
road: Uint16Array, // Road ID
|
||||
route: Uint16Array, // Route ID
|
||||
crossroad: Uint16Array,
|
||||
province: Uint16Array,
|
||||
state: Uint16Array, // State ownership
|
||||
religion: Uint16Array
|
||||
},
|
||||
vertices: {
|
||||
p: Array, // Point coordinates [x, y]
|
||||
v: Array, // Adjacent vertices
|
||||
c: Array // Adjacent cells
|
||||
},
|
||||
features: Array, // Biome/terrain features
|
||||
cultures: Array, // Culture definitions
|
||||
states: Array, // Political states
|
||||
burgs: Array, // Cities/towns/settlements
|
||||
religions: Array, // Religion definitions
|
||||
provinces: Array, // Province data
|
||||
rivers: Array, // River definitions
|
||||
markers: Array, // Map markers
|
||||
notes: Array // User notes
|
||||
}
|
||||
```
|
||||
|
||||
### Typed Array Constants
|
||||
|
||||
```javascript
|
||||
// From main.js:16-20
|
||||
const INT8_MAX = 127;
|
||||
const UINT8_MAX = 255;
|
||||
const UINT16_MAX = 65535;
|
||||
const UINT32_MAX = 4294967295;
|
||||
```
|
||||
|
||||
### Map Generation Pipeline
|
||||
|
||||
```
|
||||
Heightmap Generation
|
||||
↓
|
||||
Voronoi/Delaunay Mesh
|
||||
↓
|
||||
Biomes & Climate
|
||||
↓
|
||||
Rivers & Water Features
|
||||
↓
|
||||
Cultures Assignment
|
||||
↓
|
||||
States & Capitals
|
||||
↓
|
||||
Burgs (Cities/Towns)
|
||||
↓
|
||||
Provinces Subdivision
|
||||
↓
|
||||
Routes (Roads/Trails)
|
||||
↓
|
||||
Religions Distribution
|
||||
↓
|
||||
Military Units
|
||||
↓
|
||||
Final Rendering
|
||||
```
|
||||
|
||||
## SVG Layer Organization
|
||||
|
||||
FMG uses 70+ predefined SVG layer groups in specific render order (from main.js:40-94):
|
||||
|
||||
```javascript
|
||||
// Background to foreground rendering order
|
||||
ocean → oceanLayers → oceanPattern → lakes → landmass →
|
||||
texture → terrs → biomes → cells → rivers → terrain →
|
||||
regions → borders → routes → temperature → coastline →
|
||||
ice → population → emblems → labels → icons → armies →
|
||||
markers → fogging → ruler → debug
|
||||
```
|
||||
|
||||
**Important**: When modifying rendering, respect this layer order to avoid z-index issues.
|
||||
|
||||
## Code Conventions & Style
|
||||
|
||||
### General Style
|
||||
|
||||
1. **Strict Mode**: Every file starts with `"use strict";`
|
||||
2. **No Semicolons**: Generally omitted (but inconsistent)
|
||||
3. **Naming Conventions**:
|
||||
- `camelCase` for variables and functions
|
||||
- `PascalCase` for classes
|
||||
- `SCREAMING_SNAKE_CASE` for constants
|
||||
4. **Comments**: Minimal; code is mostly self-documenting
|
||||
5. **String Quotes**: Mixed single and double quotes (no standard)
|
||||
|
||||
### Module Patterns
|
||||
|
||||
**IIFE (Immediately Invoked Function Expression)**:
|
||||
|
||||
```javascript
|
||||
// From burgs-and-states.js:3
|
||||
window.BurgsAndStates = (() => {
|
||||
const generate = () => {
|
||||
// Implementation
|
||||
};
|
||||
|
||||
return { generate };
|
||||
})();
|
||||
|
||||
// Usage elsewhere:
|
||||
BurgsAndStates.generate();
|
||||
```
|
||||
|
||||
**Global Function Pattern**:
|
||||
|
||||
```javascript
|
||||
// From draw-borders.js:3
|
||||
function drawBorders() {
|
||||
// Direct global function
|
||||
}
|
||||
```
|
||||
|
||||
**Utility Module Pattern**:
|
||||
|
||||
```javascript
|
||||
// From utils/commonUtils.js
|
||||
function debounce(func, ms) { /* ... */ }
|
||||
function throttle(func, ms) { /* ... */ }
|
||||
// Directly callable globally
|
||||
```
|
||||
|
||||
### Loading Strategy
|
||||
|
||||
**index.html** loads scripts in three phases:
|
||||
|
||||
1. **Libraries** (D3, jQuery, etc.)
|
||||
2. **Core Modules** (synchronous, no `defer`)
|
||||
3. **UI Modules** (with `defer` attribute)
|
||||
4. **Dynamic Imports** (ES6 `import()` for lazy loading)
|
||||
|
||||
Example from index.html:
|
||||
```html
|
||||
<!-- Core generators loaded first -->
|
||||
<script src="modules/names-generator.js?v=1.108.11"></script>
|
||||
<script src="modules/cultures-generator.js?v=1.108.11"></script>
|
||||
<script src="modules/burgs-and-states.js?v=1.108.11"></script>
|
||||
<!-- ... -->
|
||||
<script src="main.js?v=1.108.11"></script>
|
||||
|
||||
<!-- UI modules loaded with defer -->
|
||||
<script defer src="modules/ui/editors.js?v=1.108.11"></script>
|
||||
<script defer src="modules/ui/heightmap-editor.js?v=1.108.11"></script>
|
||||
```
|
||||
|
||||
### Debug Flags
|
||||
|
||||
```javascript
|
||||
// From main.js:5-11
|
||||
const PRODUCTION = location.hostname &&
|
||||
location.hostname !== "localhost" &&
|
||||
location.hostname !== "127.0.0.1";
|
||||
const DEBUG = JSON.safeParse(localStorage.getItem("debug")) || {};
|
||||
const INFO = true;
|
||||
const TIME = true; // Performance timing
|
||||
const WARN = true;
|
||||
const ERROR = true;
|
||||
|
||||
// Usage:
|
||||
TIME && console.time("placeCapitals");
|
||||
// ... expensive operation ...
|
||||
TIME && console.timeEnd("placeCapitals");
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### No Build System
|
||||
|
||||
- **Zero build configuration**: No webpack, rollup, or bundlers
|
||||
- **No package.json**: No npm dependencies
|
||||
- **No transpilation**: No Babel or TypeScript
|
||||
- **Direct file editing**: Edit JS → Refresh browser → See changes
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Python simple server
|
||||
python -m http.server 8080
|
||||
|
||||
# PHP built-in server
|
||||
php -S localhost:8080
|
||||
|
||||
# Then visit: http://localhost:8080
|
||||
```
|
||||
|
||||
### Versioning Process
|
||||
|
||||
**Manual 3-step versioning** (from pull_request_template.md):
|
||||
|
||||
1. **Update VERSION** in `versioning.js`:
|
||||
```javascript
|
||||
const VERSION = "1.108.12"; // Increment using semver
|
||||
```
|
||||
|
||||
2. **Update file hashes** in `index.html` for all changed files:
|
||||
```html
|
||||
<script src="modules/burgs-and-states.js?v=1.108.12"></script>
|
||||
```
|
||||
|
||||
3. **Update changelog** in `showUpdateWindow()` function (versioning.js) if user-facing
|
||||
|
||||
### Git Workflow
|
||||
|
||||
**Commit Message Format** (inferred from git log):
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
Examples:
|
||||
fix(v1.108.11): add external icons to export in base64 format
|
||||
feat(ai-generator): update supported AI models list
|
||||
refactor: drawReliefIcons, v1.108.4
|
||||
perf: set text-rendering to optimizeSpeed, v1.108.1
|
||||
chore: update version to 1.108.8
|
||||
```
|
||||
|
||||
**Types**:
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `refactor`: Code restructuring
|
||||
- `perf`: Performance improvement
|
||||
- `chore`: Maintenance tasks
|
||||
- `docs`: Documentation changes
|
||||
|
||||
**Scopes**: Optional, often includes version number or component name
|
||||
|
||||
### Pull Request Requirements
|
||||
|
||||
From `.github/pull_request_template.md`:
|
||||
|
||||
**Required**:
|
||||
- [ ] Description of change and motivation
|
||||
- [ ] Type of change (bug fix, feature, refactor, docs, other)
|
||||
- [ ] Version updated in `versioning.js`
|
||||
- [ ] Changed files hash updated in `index.html`
|
||||
|
||||
**Before submitting**:
|
||||
1. Test locally (no automated tests)
|
||||
2. Check console for errors
|
||||
3. Verify map generation still works
|
||||
4. Update version following semver
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Adding a New Feature
|
||||
|
||||
1. **Identify the module type**:
|
||||
- Generator? → `modules/`
|
||||
- UI Editor? → `modules/ui/`
|
||||
- Renderer? → `modules/renderers/`
|
||||
- Utility? → `utils/`
|
||||
|
||||
2. **Create the module file**:
|
||||
```javascript
|
||||
"use strict";
|
||||
|
||||
window.MyNewFeature = (() => {
|
||||
const generate = () => {
|
||||
TIME && console.time("myNewFeature");
|
||||
// Implementation
|
||||
TIME && console.timeEnd("myNewFeature");
|
||||
};
|
||||
|
||||
return { generate };
|
||||
})();
|
||||
```
|
||||
|
||||
3. **Add script tag to index.html**:
|
||||
```html
|
||||
<script src="modules/my-new-feature.js?v=1.108.12"></script>
|
||||
```
|
||||
|
||||
4. **Update versioning**:
|
||||
- Increment VERSION in `versioning.js`
|
||||
- Update hash in `index.html`
|
||||
- Add changelog entry if user-facing
|
||||
|
||||
### Modifying the Data Model
|
||||
|
||||
1. **Update pack structure** in relevant generator
|
||||
2. **Update save/load** in `modules/io/save.js` and `modules/io/load.js`
|
||||
3. **Test with existing .map files** to ensure backward compatibility
|
||||
4. **Update any renderers** that use the new data
|
||||
|
||||
### Adding a New Renderer
|
||||
|
||||
1. **Create renderer file** in `modules/renderers/`:
|
||||
```javascript
|
||||
"use strict";
|
||||
|
||||
function drawMyFeature() {
|
||||
const { cells, myFeatures } = pack;
|
||||
const container = svg.select("#myFeatureLayer");
|
||||
|
||||
// D3 rendering logic
|
||||
container.selectAll("path")
|
||||
.data(myFeatures)
|
||||
.join("path")
|
||||
.attr("d", d => d.path)
|
||||
.attr("fill", d => d.color);
|
||||
}
|
||||
```
|
||||
|
||||
2. **Create SVG layer** in main.js:
|
||||
```javascript
|
||||
let myFeatureLayer = viewbox.append("g")
|
||||
.attr("id", "myFeatureLayer");
|
||||
```
|
||||
|
||||
3. **Add to render pipeline** in appropriate location
|
||||
|
||||
### Debugging Tips
|
||||
|
||||
1. **Use DEBUG flags**:
|
||||
```javascript
|
||||
DEBUG && console.log("Debug info:", data);
|
||||
TIME && console.time("expensiveOperation");
|
||||
```
|
||||
|
||||
2. **Check the debug SVG layer**:
|
||||
```javascript
|
||||
debug.append("circle")
|
||||
.attr("cx", x)
|
||||
.attr("cy", y)
|
||||
.attr("r", 5)
|
||||
.attr("fill", "red");
|
||||
```
|
||||
|
||||
3. **Use browser DevTools**:
|
||||
- Network tab: Check script loading
|
||||
- Console: Look for TIME logs
|
||||
- Sources: Set breakpoints
|
||||
|
||||
4. **Test map generation**:
|
||||
- Generate → Verify no console errors
|
||||
- Save → Load → Verify data integrity
|
||||
- Export → Check output quality
|
||||
|
||||
## Testing Approach
|
||||
|
||||
**No formal testing framework**:
|
||||
- No Jest, Mocha, or automated tests
|
||||
- Manual testing only
|
||||
- User reports via GitHub issues
|
||||
|
||||
**Manual testing checklist**:
|
||||
1. Generate new map with default settings
|
||||
2. Generate with various custom settings
|
||||
3. Load existing .map files
|
||||
4. Test all editors (heightmap, states, cultures, etc.)
|
||||
5. Export in all formats (SVG, PNG, JSON)
|
||||
6. Check console for errors/warnings
|
||||
7. Test on multiple browsers (Chrome, Firefox, Safari)
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Optimization Strategies
|
||||
|
||||
1. **Typed Arrays**: Use for large datasets
|
||||
```javascript
|
||||
cells.h = new Uint8Array(n); // Heights 0-255
|
||||
cells.pop = new Float32Array(n); // Population
|
||||
```
|
||||
|
||||
2. **D3 Data Binding**: Efficient DOM updates
|
||||
```javascript
|
||||
container.selectAll("path")
|
||||
.data(features, d => d.id) // Key function
|
||||
.join("path") // Efficient enter/update/exit
|
||||
```
|
||||
|
||||
3. **Debouncing/Throttling**: For frequent events
|
||||
```javascript
|
||||
const onMouseMove = debounce(handleMouseMove, 100);
|
||||
```
|
||||
|
||||
4. **Quadtree for Spatial Queries**:
|
||||
```javascript
|
||||
let burgsTree = d3.quadtree();
|
||||
burgsTree.add([x, y]);
|
||||
const nearest = burgsTree.find(x, y, radius);
|
||||
```
|
||||
|
||||
5. **IndexedDB**: For large map storage
|
||||
```javascript
|
||||
// See libs/indexedDB.js
|
||||
```
|
||||
|
||||
## Important Patterns & Anti-Patterns
|
||||
|
||||
### DO:
|
||||
|
||||
✅ Use typed arrays for cell data
|
||||
✅ Respect SVG layer rendering order
|
||||
✅ Use TIME flags for performance monitoring
|
||||
✅ Follow IIFE pattern for new modules
|
||||
✅ Update version numbers consistently
|
||||
✅ Test backward compatibility with old .map files
|
||||
✅ Use D3 data binding for DOM updates
|
||||
✅ Check for null/undefined before accessing pack data
|
||||
|
||||
### DON'T:
|
||||
|
||||
❌ Add npm dependencies (no build system)
|
||||
❌ Use ES6 modules (not supported in current architecture)
|
||||
❌ Modify global data structures directly without updating renderers
|
||||
❌ Add large libraries (keep bundle size manageable)
|
||||
❌ Break backward compatibility without migration logic
|
||||
❌ Add features without updating save/load functionality
|
||||
❌ Forget to update version hash in index.html
|
||||
❌ Mix rendering layers (respect z-order)
|
||||
|
||||
## Common Patterns in Codebase
|
||||
|
||||
### Random Number Generation
|
||||
|
||||
```javascript
|
||||
// Use seeded random for reproducibility
|
||||
const rand = aleaPRNG(seed);
|
||||
const value = rand(); // 0-1
|
||||
|
||||
// Gaussian distribution
|
||||
const value = gauss(mean, deviation, min, max, rounds);
|
||||
```
|
||||
|
||||
### Cell Iteration
|
||||
|
||||
```javascript
|
||||
const { cells } = pack;
|
||||
const n = cells.i.length;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (cells.h[i] < 20) continue; // Skip water
|
||||
// Process land cells
|
||||
}
|
||||
```
|
||||
|
||||
### D3 SVG Path Creation
|
||||
|
||||
```javascript
|
||||
const path = d3.line()
|
||||
.x(d => d[0])
|
||||
.y(d => d[1])
|
||||
.curve(d3.curveBasis);
|
||||
|
||||
const pathString = path(points);
|
||||
```
|
||||
|
||||
### Graph Traversal
|
||||
|
||||
```javascript
|
||||
// BFS example from graphUtils.js
|
||||
const queue = [startCell];
|
||||
const visited = new Uint8Array(cells.i.length);
|
||||
|
||||
while (queue.length) {
|
||||
const cell = queue.shift();
|
||||
if (visited[cell]) continue;
|
||||
visited[cell] = 1;
|
||||
|
||||
cells.c[cell].forEach(neighbor => {
|
||||
if (!visited[neighbor]) queue.push(neighbor);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Voronoi/Delaunay Mesh
|
||||
|
||||
FMG uses a Voronoi diagram as the base map structure:
|
||||
|
||||
- **Cells**: Voronoi regions representing map areas
|
||||
- **Vertices**: Points where 3+ cells meet
|
||||
- **Edges**: Borders between cells
|
||||
- **Delaunay Triangulation**: Dual graph for efficient pathfinding
|
||||
|
||||
**Implementation**: Custom `Voronoi` class in `modules/voronoi.js` using Delaunator library
|
||||
|
||||
### Heightmap System
|
||||
|
||||
Heights stored as `Uint8Array` (0-255):
|
||||
- **0-19**: Ocean depths
|
||||
- **20**: Sea level
|
||||
- **21-99**: Land elevations
|
||||
- **100+**: Mountains/peaks (clamped to 255)
|
||||
|
||||
### Culture & State System
|
||||
|
||||
**Cultures**: Linguistic/ethnic groups with naming patterns
|
||||
**States**: Political entities with territories, capitals, military
|
||||
|
||||
Both use expansion algorithms based on cell scoring and distance from capitals.
|
||||
|
||||
### Name Generation
|
||||
|
||||
Sophisticated system in `modules/names-generator.js` (3,371 lines):
|
||||
- Cultural naming patterns
|
||||
- Procedural phoneme generation
|
||||
- Linguistic rules for realistic names
|
||||
- Separate generators for burgs, states, cultures, features
|
||||
|
||||
## File Modification Guidelines
|
||||
|
||||
### When Editing index.html
|
||||
|
||||
- **Line count**: 8,184 lines - VERY large file
|
||||
- **Structure**: UI components inline (not templated)
|
||||
- **Script tags**: Update version hash when modifying JS files
|
||||
- **Dialogs**: jQuery UI dialogs defined inline
|
||||
- **Be careful**: Easy to break HTML structure
|
||||
|
||||
### When Editing main.js
|
||||
|
||||
- **Global scope**: Everything here is globally accessible
|
||||
- **SVG layers**: Order matters (lines 40-94)
|
||||
- **Constants**: Typed array max values defined here
|
||||
- **Initialization**: Core setup happens here
|
||||
|
||||
### When Editing Generators
|
||||
|
||||
- **Self-contained**: Each generator should be independent
|
||||
- **Timing**: Wrap in TIME && console.time/timeEnd
|
||||
- **Error handling**: Use WARN && console.warn for issues
|
||||
- **Data mutations**: Update pack object directly
|
||||
|
||||
### When Editing Renderers
|
||||
|
||||
- **Layer awareness**: Know which SVG layer you're drawing to
|
||||
- **Clear old content**: Remove previous render before redrawing
|
||||
- **D3 data binding**: Use .join() for efficient updates
|
||||
- **Performance**: Large renders should be optimized
|
||||
|
||||
## Resources
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Project Wiki**: https://github.com/Azgaar/Fantasy-Map-Generator/wiki
|
||||
- **Data Model**: https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Data-model
|
||||
- **Trello Board**: https://trello.com/b/7x832DG4/fantasy-map-generator
|
||||
- **Blog**: https://azgaar.wordpress.com
|
||||
|
||||
### Community
|
||||
|
||||
- **Discord**: https://discordapp.com/invite/X7E84HU
|
||||
- **Reddit**: https://www.reddit.com/r/FantasyMapGenerator
|
||||
- **GitHub Issues**: https://github.com/Azgaar/Fantasy-Map-Generator/issues
|
||||
|
||||
### Support
|
||||
|
||||
- **Patreon**: https://www.patreon.com/azgaar
|
||||
- **Email**: azgaar.fmg@yandex.com
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### Map Won't Generate
|
||||
|
||||
1. Check console for errors
|
||||
2. Verify all scripts loaded (Network tab)
|
||||
3. Check localStorage isn't corrupted
|
||||
4. Try clearing browser cache
|
||||
|
||||
### Save/Load Failures
|
||||
|
||||
1. Check IndexedDB quota
|
||||
2. Verify .map file format (should be valid JSON)
|
||||
3. Check version compatibility
|
||||
4. Look for migration errors in console
|
||||
|
||||
### Performance Issues
|
||||
|
||||
1. Reduce number of cells (Options → Map Size)
|
||||
2. Disable unused layers
|
||||
3. Use simpler rendering styles
|
||||
4. Check for memory leaks in console
|
||||
|
||||
### Rendering Glitches
|
||||
|
||||
1. Verify SVG layer order
|
||||
2. Check for NaN values in coordinates
|
||||
3. Ensure paths are valid SVG syntax
|
||||
4. Test in different browsers
|
||||
|
||||
## Best Practices for AI Assistants
|
||||
|
||||
### When Analyzing Code
|
||||
|
||||
1. **Start with the data model**: Understand `pack` structure
|
||||
2. **Check dependencies**: See what functions/modules are used
|
||||
3. **Look for patterns**: IIFE modules, global functions, D3 usage
|
||||
4. **Trace data flow**: How data moves through generation pipeline
|
||||
|
||||
### When Making Changes
|
||||
|
||||
1. **Test locally first**: No CI/CD, manual testing required
|
||||
2. **Update version properly**: Follow 3-step process
|
||||
3. **Maintain backward compatibility**: Old .map files should still load
|
||||
4. **Document complex logic**: Code is under-commented
|
||||
5. **Check performance**: Use TIME flags to measure
|
||||
|
||||
### When Refactoring
|
||||
|
||||
1. **Small incremental changes**: Large refactors are risky
|
||||
2. **Keep the same API**: Don't break existing integrations
|
||||
3. **Test all features**: Generator, editors, save/load, export
|
||||
4. **Consider deprecation**: Don't remove features abruptly
|
||||
|
||||
### When Adding Features
|
||||
|
||||
1. **Check existing code**: Similar features might exist
|
||||
2. **Follow existing patterns**: IIFE for modules, global for utils
|
||||
3. **Update save/load**: Persist new data properly
|
||||
4. **Add UI if needed**: Users should access the feature
|
||||
5. **Update documentation**: At least in comments/changelog
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### File to Edit By Task
|
||||
|
||||
| Task | Files to Edit |
|
||||
|------|--------------|
|
||||
| Add new generator | `modules/my-generator.js`, `index.html` (script tag) |
|
||||
| Modify UI editor | `modules/ui/my-editor.js` |
|
||||
| Change rendering | `modules/renderers/draw-my-feature.js` |
|
||||
| Add utility function | `utils/myUtils.js` |
|
||||
| Update save format | `modules/io/save.js`, `modules/io/load.js` |
|
||||
| Change map options | `modules/ui/options.js`, `index.html` (UI) |
|
||||
| Add SVG layer | `main.js` (lines 40-94), renderer file |
|
||||
| Update version | `versioning.js`, `index.html` (all script tags) |
|
||||
|
||||
### Common Functions
|
||||
|
||||
| Function | Location | Purpose |
|
||||
|----------|----------|---------|
|
||||
| `rn(value, decimals)` | `utils/numberUtils.js` | Round number |
|
||||
| `rand(min, max)` | Random utilities | Random in range |
|
||||
| `gauss(mean, dev, ...)` | `utils/probabilityUtils.js` | Gaussian distribution |
|
||||
| `debounce(fn, ms)` | `utils/commonUtils.js` | Debounce function |
|
||||
| `P(probability)` | Probability utils | Random boolean |
|
||||
| `ra(array)` | Array utils | Random array element |
|
||||
|
||||
### Common D3 Patterns
|
||||
|
||||
```javascript
|
||||
// Select SVG layer
|
||||
const layer = svg.select("#myLayer");
|
||||
|
||||
// Data binding
|
||||
layer.selectAll("path")
|
||||
.data(features, d => d.id)
|
||||
.join("path")
|
||||
.attr("d", d => d.path);
|
||||
|
||||
// Zoom/pan
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([1, 20])
|
||||
.on("zoom", zoomed);
|
||||
|
||||
svg.call(zoom);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Fantasy Map Generator is a **unique codebase** that prioritizes:
|
||||
|
||||
1. **Accessibility**: Runs anywhere without build tools
|
||||
2. **Simplicity**: Direct file editing, no complex tooling
|
||||
3. **Performance**: Typed arrays, efficient algorithms
|
||||
4. **User experience**: Rich UI with extensive customization
|
||||
|
||||
When working on FMG, embrace its philosophy: **pragmatic simplicity over modern complexity**. The lack of build tools is intentional, making it easy for contributors to jump in without setup overhead.
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2025-11-14*
|
||||
*Current Version: 1.108.11*
|
||||
*This guide is for AI assistants working on Fantasy Map Generator codebase.*
|
||||
270
OBSIDIAN_INTEGRATION.md
Normal file
270
OBSIDIAN_INTEGRATION.md
Normal 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! 🗺️✨**
|
||||
127
index.html
127
index.html
|
|
@ -1830,6 +1830,7 @@
|
|||
<td>
|
||||
<select id="onloadBehavior" data-stored="onloadBehavior">
|
||||
<option value="random" selected>Generate random map</option>
|
||||
<option value="default">Open default map</option>
|
||||
<option value="lastSaved">Open last saved map</option>
|
||||
</select>
|
||||
</td>
|
||||
|
|
@ -2061,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>
|
||||
|
|
@ -4962,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>
|
||||
|
|
@ -6087,6 +6192,15 @@
|
|||
browser
|
||||
</button>
|
||||
</div>
|
||||
<div style="margin-top: 0.8em; padding-top: 0.8em; border-top: 1px solid #ccc">
|
||||
<strong>Default map</strong>
|
||||
<button onclick="saveAsDefaultMap()" data-tip="Set this map as the default that opens on load (requires 'Open default map' onload behavior)">
|
||||
Set as default
|
||||
</button>
|
||||
<button onclick="clearDefaultMap()" data-tip="Clear the current default map setting">
|
||||
Clear default
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
Maps are saved in <i>.map</i> format, that can be loaded back via the <i>Load</i> in menu. There is no way to
|
||||
restore the progress if file is lost. Please keep old save files on your machine or cloud storage as backups.
|
||||
|
|
@ -8118,7 +8232,7 @@
|
|||
<script src="modules/ui/style-presets.js?v=1.100.00"></script>
|
||||
<script src="modules/ui/general.js?v=1.100.00"></script>
|
||||
<script src="modules/ui/options.js?v=1.106.2"></script>
|
||||
<script src="main.js?v=1.108.1"></script>
|
||||
<script src="main.js?v=1.108.13">
|
||||
|
||||
<script defer src="modules/ui/style.js?v=1.108.4"></script>
|
||||
<script defer src="modules/ui/editors.js?v=1.108.5"></script>
|
||||
|
|
@ -8140,7 +8254,7 @@
|
|||
<script defer src="modules/ui/rivers-editor.js?v=1.106.0"></script>
|
||||
<script defer src="modules/ui/rivers-creator.js?v=1.106.0"></script>
|
||||
<script defer src="modules/ui/relief-editor.js?v=1.99.00"></script>
|
||||
<script defer src="modules/ui/burg-editor.js?v=1.106.6"></script>
|
||||
<script defer src="modules/ui/burg-editor.js?v=1.108.13.1"></script>
|
||||
<script defer src="modules/ui/units-editor.js?v=1.108.12"></script>
|
||||
<script defer src="modules/ui/notes-editor.js?v=1.107.3"></script>
|
||||
<script defer src="modules/ui/ai-generator.js?v=1.108.8"></script>
|
||||
|
|
@ -8155,7 +8269,7 @@
|
|||
<script defer src="modules/ui/regiment-editor.js?v=1.108.5"></script>
|
||||
<script defer src="modules/ui/battle-screen.js?v=1.108.5"></script>
|
||||
<script defer src="modules/ui/emblems-editor.js?v=1.99.00"></script>
|
||||
<script defer src="modules/ui/markers-editor.js?v=1.108.5"></script>
|
||||
<script defer src="modules/ui/markers-editor.js?v=1.108.13.1"></script>
|
||||
<script defer src="modules/ui/3d.js?v=1.99.00"></script>
|
||||
<script defer src="modules/ui/submap-tool.js?v=1.106.2"></script>
|
||||
<script defer src="modules/ui/transform-tool.js?v=1.106.2"></script>
|
||||
|
|
@ -8163,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.107.4"></script>
|
||||
<script defer src="modules/io/load.js?v=1.108.0"></script>
|
||||
<script defer src="modules/io/save.js?v=1.108.13"></script>
|
||||
<script defer src="modules/io/load.js?v=1.108.13"></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.3"></script>
|
||||
<script defer src="modules/ui/obsidian-notes-editor.js?v=1.108.13.4"></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>
|
||||
|
|
|
|||
22
main.js
22
main.js
|
|
@ -291,6 +291,28 @@ async function checkLoadParameters() {
|
|||
return;
|
||||
}
|
||||
|
||||
// restore onloadBehavior from localStorage if saved
|
||||
const storedBehavior = localStorage.getItem("onloadBehavior");
|
||||
if (storedBehavior) {
|
||||
byId("onloadBehavior").value = storedBehavior;
|
||||
}
|
||||
|
||||
// check if there is a default map saved to indexedDB
|
||||
if (byId("onloadBehavior").value === "default") {
|
||||
try {
|
||||
const blob = await ldb.get("defaultMap");
|
||||
if (blob) {
|
||||
WARN && console.warn("Loading default map");
|
||||
uploadMap(blob);
|
||||
return;
|
||||
} else {
|
||||
WARN && console.warn("No default map set, generating random map");
|
||||
}
|
||||
} catch (error) {
|
||||
ERROR && console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// check if there is a map saved to indexedDB
|
||||
if (byId("onloadBehavior").value === "lastSaved") {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -198,17 +198,22 @@ window.BurgsAndStates = (() => {
|
|||
else b.y = rn(b.y - shift, 2);
|
||||
}
|
||||
|
||||
// define emblem
|
||||
const state = pack.states[b.state];
|
||||
const stateCOA = state.coa;
|
||||
let kinship = 0.25;
|
||||
if (b.capital) kinship += 0.1;
|
||||
else if (b.port) kinship -= 0.1;
|
||||
if (b.culture !== state.culture) kinship -= 0.25;
|
||||
b.type = getType(i, b.port);
|
||||
const type = b.capital && P(0.2) ? "Capital" : b.type === "Generic" ? "City" : b.type;
|
||||
b.coa = COA.generate(stateCOA, kinship, null, type);
|
||||
b.coa.shield = COA.getShield(b.culture, b.state);
|
||||
// Attach biome info
|
||||
b.biome = pack.cells.biome[i];
|
||||
// Attach province info if available
|
||||
b.province = pack.cells.province ? pack.cells.province[i] : undefined;
|
||||
|
||||
// define emblem
|
||||
const state = pack.states[b.state];
|
||||
const stateCOA = state.coa;
|
||||
let kinship = 0.25;
|
||||
if (b.capital) kinship += 0.1;
|
||||
else if (b.port) kinship -= 0.1;
|
||||
if (b.culture !== state.culture) kinship -= 0.25;
|
||||
b.type = getType(i, b.port);
|
||||
const type = b.capital && P(0.2) ? "Capital" : b.type === "Generic" ? "City" : b.type;
|
||||
b.coa = COA.generate(stateCOA, kinship, null, type);
|
||||
b.coa.shield = COA.getShield(b.culture, b.state);
|
||||
}
|
||||
|
||||
// de-assign port status if it's the only one on feature
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ async function parseLoadedData(data, mapVersion) {
|
|||
family === usedFamily && unicodeRange === usedRange && variant === usedVariant
|
||||
);
|
||||
if (!defaultFont) fonts.push(usedFont);
|
||||
declareFont(usedFont);
|
||||
if (typeof declareFont !== "undefined") declareFont(usedFont);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -460,7 +460,7 @@ async function parseLoadedData(data, mapVersion) {
|
|||
if (isVisible(scaleBar)) turnOn("toggleScaleBar");
|
||||
if (isVisibleNode(byId("vignette"))) turnOn("toggleVignette");
|
||||
|
||||
getCurrentPreset();
|
||||
if (typeof getCurrentPreset !== "undefined") getCurrentPreset();
|
||||
}
|
||||
|
||||
{
|
||||
|
|
@ -477,7 +477,7 @@ async function parseLoadedData(data, mapVersion) {
|
|||
}
|
||||
|
||||
// add custom heightmap color scheme if any
|
||||
if (heightmapColorSchemes) {
|
||||
if (typeof heightmapColorSchemes !== "undefined" && heightmapColorSchemes && typeof addCustomColorScheme !== "undefined") {
|
||||
const oceanScheme = byId("oceanHeights")?.getAttribute("scheme");
|
||||
if (oceanScheme && !(oceanScheme in heightmapColorSchemes)) addCustomColorScheme(oceanScheme);
|
||||
const landScheme = byId("#landHeights")?.getAttribute("scheme");
|
||||
|
|
@ -487,7 +487,7 @@ async function parseLoadedData(data, mapVersion) {
|
|||
{
|
||||
// add custom texture if any
|
||||
const textureHref = texture.attr("data-href");
|
||||
if (textureHref) updateTextureSelectValue(textureHref);
|
||||
if (textureHref && typeof updateTextureSelectValue !== "undefined") updateTextureSelectValue(textureHref);
|
||||
}
|
||||
|
||||
// data integrity checks
|
||||
|
|
@ -625,7 +625,7 @@ async function parseLoadedData(data, mapVersion) {
|
|||
|
||||
capitalBurgs.forEach(burg => {
|
||||
burg.capital = 0;
|
||||
moveBurgToGroup(burg.i, "towns");
|
||||
if (typeof moveBurgToGroup !== "undefined") moveBurgToGroup(burg.i, "towns");
|
||||
});
|
||||
|
||||
return;
|
||||
|
|
@ -640,7 +640,7 @@ async function parseLoadedData(data, mapVersion) {
|
|||
capitalBurgs.forEach((burg, i) => {
|
||||
if (!i) return;
|
||||
burg.capital = 0;
|
||||
moveBurgToGroup(burg.i, "towns");
|
||||
if (typeof moveBurgToGroup !== "undefined") moveBurgToGroup(burg.i, "towns");
|
||||
});
|
||||
|
||||
return;
|
||||
|
|
@ -650,7 +650,7 @@ async function parseLoadedData(data, mapVersion) {
|
|||
ERROR &&
|
||||
console.error(`[Data integrity] State ${state.i} has no capital. Assigning the first burg as capital`);
|
||||
stateBurgs[0].capital = 1;
|
||||
moveBurgToGroup(stateBurgs[0].i, "cities");
|
||||
if (typeof moveBurgToGroup !== "undefined") moveBurgToGroup(stateBurgs[0].i, "cities");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -731,9 +731,9 @@ async function parseLoadedData(data, mapVersion) {
|
|||
|
||||
{
|
||||
if (window.restoreDefaultEvents) restoreDefaultEvents();
|
||||
focusOn(); // based on searchParams focus on point, cell or burg
|
||||
invokeActiveZooming();
|
||||
fitMapToScreen();
|
||||
if (typeof focusOn !== "undefined") focusOn(); // based on searchParams focus on point, cell or burg
|
||||
if (typeof invokeActiveZooming !== "undefined") invokeActiveZooming();
|
||||
if (typeof fitMapToScreen !== "undefined") fitMapToScreen();
|
||||
}
|
||||
|
||||
WARN && console.warn(`TOTAL: ${rn((performance.now() - uploadMap.timeStart) / 1000, 2)}s`);
|
||||
|
|
|
|||
710
modules/io/obsidian-bridge.js
Normal file
710
modules/io/obsidian-bridge.js
Normal file
|
|
@ -0,0 +1,710 @@
|
|||
"use strict";
|
||||
|
||||
// Obsidian Vault Integration for Fantasy Map Generator
|
||||
// Uses Obsidian Local REST API plugin
|
||||
// https://github.com/coddingtonbear/obsidian-local-rest-api
|
||||
|
||||
const ObsidianBridge = (() => {
|
||||
// Configuration
|
||||
const config = {
|
||||
apiUrl: "http://127.0.0.1:27123",
|
||||
apiKey: "", // Set via UI
|
||||
enabled: false,
|
||||
vaultName: ""
|
||||
};
|
||||
|
||||
// Cache for vault file list
|
||||
let vaultFilesCache = {
|
||||
files: null,
|
||||
timestamp: null,
|
||||
ttl: 5 * 60 * 1000 // 5 minutes cache
|
||||
};
|
||||
|
||||
// Index: fmg-id → file path for fast lookups
|
||||
let fmgIdIndex = {};
|
||||
|
||||
// Initialize from localStorage
|
||||
function init() {
|
||||
const stored = localStorage.getItem("obsidianConfig");
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
Object.assign(config, parsed);
|
||||
} catch (error) {
|
||||
ERROR && console.error("Failed to load Obsidian config:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load FMG ID index from localStorage
|
||||
const storedIndex = localStorage.getItem("obsidianFmgIdIndex");
|
||||
if (storedIndex) {
|
||||
try {
|
||||
fmgIdIndex = JSON.parse(storedIndex);
|
||||
INFO && console.log(`Loaded FMG ID index with ${Object.keys(fmgIdIndex).length} entries`);
|
||||
} catch (error) {
|
||||
ERROR && console.error("Failed to load FMG ID index:", error);
|
||||
fmgIdIndex = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-warm cache if Obsidian is already enabled
|
||||
if (config.enabled) {
|
||||
INFO && console.log("Obsidian enabled, pre-warming cache...");
|
||||
prewarmCache();
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// Pre-warm the cache in the background (don't await)
|
||||
prewarmCache();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
ERROR && console.error("Obsidian connection failed:", error);
|
||||
config.enabled = false;
|
||||
saveConfig();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-warm the vault files cache in the background
|
||||
async function prewarmCache() {
|
||||
try {
|
||||
INFO && console.log("Pre-warming vault file cache...");
|
||||
await getVaultFiles();
|
||||
INFO && console.log("Vault file cache pre-warmed successfully!");
|
||||
|
||||
// Also build the complete FMG ID index
|
||||
await buildCompleteIndex();
|
||||
} catch (error) {
|
||||
WARN && console.warn("Failed to pre-warm cache:", error);
|
||||
// Don't throw - this is just optimization
|
||||
}
|
||||
}
|
||||
|
||||
// Build complete index of all fmg-ids in the vault
|
||||
async function buildCompleteIndex() {
|
||||
try {
|
||||
INFO && console.log("Building complete FMG ID index...");
|
||||
TIME && console.time("buildCompleteIndex");
|
||||
|
||||
const files = vaultFilesCache.files || await getVaultFiles();
|
||||
let indexed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const content = await getNote(filePath);
|
||||
const {frontmatter} = parseFrontmatter(content);
|
||||
|
||||
const fmgId = frontmatter["fmg-id"] || frontmatter.fmgId;
|
||||
if (fmgId) {
|
||||
fmgIdIndex[fmgId] = filePath;
|
||||
indexed++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (error) {
|
||||
DEBUG && console.debug(`Skipping file ${filePath}:`, error);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Save the complete index
|
||||
saveFmgIdIndex();
|
||||
|
||||
TIME && console.timeEnd("buildCompleteIndex");
|
||||
INFO && console.log(`Complete FMG ID index built: ${indexed} notes indexed, ${skipped} skipped`);
|
||||
} catch (error) {
|
||||
ERROR && console.error("Failed to build complete index:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively scan a directory and all subdirectories for .md files
|
||||
async function scanDirectory(path = "") {
|
||||
const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(path)}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch directory ${path}: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const entries = data.files || [];
|
||||
const mdFiles = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path ? `${path}${entry}` : entry;
|
||||
|
||||
if (entry.endsWith("/")) {
|
||||
// It's a directory - recurse into it
|
||||
DEBUG && console.log(`Scanning directory: ${fullPath}`);
|
||||
const subFiles = await scanDirectory(fullPath);
|
||||
mdFiles.push(...subFiles);
|
||||
} else if (entry.endsWith(".md")) {
|
||||
// It's a markdown file - add it
|
||||
mdFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return mdFiles;
|
||||
}
|
||||
|
||||
// Clear the vault files cache
|
||||
function clearVaultCache() {
|
||||
vaultFilesCache.files = null;
|
||||
vaultFilesCache.timestamp = null;
|
||||
INFO && console.log("Vault file cache cleared");
|
||||
}
|
||||
|
||||
// Save FMG ID index to localStorage
|
||||
function saveFmgIdIndex() {
|
||||
try {
|
||||
localStorage.setItem("obsidianFmgIdIndex", JSON.stringify(fmgIdIndex));
|
||||
DEBUG && console.log(`Saved FMG ID index with ${Object.keys(fmgIdIndex).length} entries`);
|
||||
} catch (error) {
|
||||
ERROR && console.error("Failed to save FMG ID index:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Add entry to FMG ID index
|
||||
function addToFmgIdIndex(fmgId, filePath) {
|
||||
if (!fmgId) return;
|
||||
fmgIdIndex[fmgId] = filePath;
|
||||
saveFmgIdIndex();
|
||||
DEBUG && console.log(`Added to index: ${fmgId} → ${filePath}`);
|
||||
}
|
||||
|
||||
// Get file path from FMG ID index
|
||||
function getFromFmgIdIndex(fmgId) {
|
||||
return fmgIdIndex[fmgId] || null;
|
||||
}
|
||||
|
||||
// Get all markdown files from vault (recursively, with caching)
|
||||
async function getVaultFiles(forceRefresh = false) {
|
||||
if (!config.enabled) {
|
||||
throw new Error("Obsidian not connected");
|
||||
}
|
||||
|
||||
// Check cache
|
||||
const now = Date.now();
|
||||
const cacheValid = vaultFilesCache.files !== null &&
|
||||
vaultFilesCache.timestamp !== null &&
|
||||
(now - vaultFilesCache.timestamp) < vaultFilesCache.ttl;
|
||||
|
||||
if (cacheValid && !forceRefresh) {
|
||||
INFO && console.log(`getVaultFiles: Using cached list (${vaultFilesCache.files.length} files)`);
|
||||
return vaultFilesCache.files;
|
||||
}
|
||||
|
||||
try {
|
||||
TIME && console.time("getVaultFiles");
|
||||
INFO && console.log("getVaultFiles: Scanning vault (cache miss or expired)...");
|
||||
|
||||
// Recursively scan all directories
|
||||
const mdFiles = await scanDirectory("");
|
||||
|
||||
// Update cache
|
||||
vaultFilesCache.files = mdFiles;
|
||||
vaultFilesCache.timestamp = now;
|
||||
|
||||
INFO && console.log(`getVaultFiles: Found ${mdFiles.length} markdown files (recursive scan, cached)`);
|
||||
DEBUG && console.log("Sample files:", mdFiles.slice(0, 10));
|
||||
|
||||
TIME && console.timeEnd("getVaultFiles");
|
||||
|
||||
return mdFiles;
|
||||
} catch (error) {
|
||||
ERROR && console.error("Failed to get vault files:", error);
|
||||
TIME && console.timeEnd("getVaultFiles");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get note content by path
|
||||
async function getNote(notePath) {
|
||||
if (!config.enabled) {
|
||||
throw new Error("Obsidian not connected");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(notePath)}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
Accept: "text/markdown"
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch note: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
ERROR && console.error("Failed to get note:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update note content
|
||||
async function updateNote(notePath, content) {
|
||||
if (!config.enabled) {
|
||||
throw new Error("Obsidian not connected");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(notePath)}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
"Content-Type": "text/markdown"
|
||||
},
|
||||
body: content
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update note: ${response.status}`);
|
||||
}
|
||||
|
||||
INFO && console.log("Note updated successfully:", notePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
ERROR && console.error("Failed to update note:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new note
|
||||
async function createNote(notePath, content) {
|
||||
if (!config.enabled) {
|
||||
throw new Error("Obsidian not connected");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(notePath)}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
"Content-Type": "text/markdown"
|
||||
},
|
||||
body: content
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create note: ${response.status}`);
|
||||
}
|
||||
|
||||
INFO && console.log("Note created successfully:", notePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
ERROR && console.error("Failed to create note:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse YAML frontmatter from markdown content
|
||||
function parseFrontmatter(content) {
|
||||
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return {frontmatter: {}, content};
|
||||
}
|
||||
|
||||
const frontmatterText = match[1];
|
||||
const bodyContent = content.slice(match[0].length).trim();
|
||||
|
||||
// Simple YAML parser (handles basic key-value pairs and nested objects)
|
||||
const frontmatter = {};
|
||||
const lines = frontmatterText.split("\n");
|
||||
let currentKey = null;
|
||||
let currentObj = frontmatter;
|
||||
let indentLevel = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
const indent = line.search(/\S/);
|
||||
const colonIndex = trimmed.indexOf(":");
|
||||
|
||||
if (colonIndex === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, colonIndex).trim();
|
||||
const value = trimmed.slice(colonIndex + 1).trim();
|
||||
|
||||
if (indent === 0) {
|
||||
currentObj = frontmatter;
|
||||
currentKey = key;
|
||||
|
||||
if (value) {
|
||||
// Simple value
|
||||
frontmatter[key] = parseValue(value);
|
||||
} else {
|
||||
// Nested object or array
|
||||
frontmatter[key] = {};
|
||||
currentObj = frontmatter[key];
|
||||
}
|
||||
} else if (currentObj && key) {
|
||||
// Nested property
|
||||
currentObj[key] = parseValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return {frontmatter, content: bodyContent};
|
||||
}
|
||||
|
||||
// Parse YAML value (handle strings, numbers, arrays)
|
||||
function parseValue(value) {
|
||||
if (!value) return null;
|
||||
|
||||
// Array
|
||||
if (value.startsWith("[") && value.endsWith("]")) {
|
||||
return value
|
||||
.slice(1, -1)
|
||||
.split(",")
|
||||
.map(v => v.trim())
|
||||
.filter(v => v);
|
||||
}
|
||||
|
||||
// Number
|
||||
if (!isNaN(value) && value !== "") {
|
||||
return parseFloat(value);
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
|
||||
// String (remove quotes if present)
|
||||
return value.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
|
||||
// Calculate distance between two coordinates
|
||||
function calculateDistance(x1, y1, x2, y2) {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
// Find notes by coordinates
|
||||
async function findNotesByCoordinates(x, y, limit = 8) {
|
||||
TIME && console.time("findNotesByCoordinates");
|
||||
|
||||
try {
|
||||
const files = await getVaultFiles();
|
||||
const mdFiles = files.filter(f => f.endsWith(".md"));
|
||||
|
||||
const matches = [];
|
||||
|
||||
for (const filePath of mdFiles) {
|
||||
try {
|
||||
const content = await getNote(filePath);
|
||||
const {frontmatter} = parseFrontmatter(content);
|
||||
|
||||
// Check for coordinates in frontmatter
|
||||
let noteX, noteY;
|
||||
|
||||
// Support various coordinate formats
|
||||
if (frontmatter.coordinates) {
|
||||
noteX = frontmatter.coordinates.x || frontmatter.coordinates.X;
|
||||
noteY = frontmatter.coordinates.y || frontmatter.coordinates.Y;
|
||||
} else {
|
||||
noteX = frontmatter.x || frontmatter.X;
|
||||
noteY = frontmatter.y || frontmatter.Y;
|
||||
}
|
||||
|
||||
if (noteX !== undefined && noteY !== undefined) {
|
||||
const distance = calculateDistance(x, y, noteX, noteY);
|
||||
|
||||
matches.push({
|
||||
path: filePath,
|
||||
name: filePath.replace(/\.md$/, "").split("/").pop(),
|
||||
frontmatter,
|
||||
distance,
|
||||
coordinates: {x: noteX, y: noteY}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip files that can't be read
|
||||
DEBUG && console.debug("Skipping file:", filePath, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by distance and return top matches
|
||||
matches.sort((a, b) => a.distance - b.distance);
|
||||
const results = matches.slice(0, limit);
|
||||
|
||||
TIME && console.timeEnd("findNotesByCoordinates");
|
||||
INFO && console.log(`Found ${results.length} nearby notes for (${x}, ${y})`);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
ERROR && console.error("Failed to find notes by coordinates:", error);
|
||||
TIME && console.timeEnd("findNotesByCoordinates");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Find note by FMG ID in frontmatter (with index for fast lookup)
|
||||
async function findNoteByFmgId(fmgId) {
|
||||
if (!fmgId) return null;
|
||||
|
||||
try {
|
||||
// First, check the index for instant lookup
|
||||
const indexedPath = getFromFmgIdIndex(fmgId);
|
||||
if (indexedPath) {
|
||||
INFO && console.log(`Found note in index: ${fmgId} → ${indexedPath}`);
|
||||
try {
|
||||
const content = await getNote(indexedPath);
|
||||
const {frontmatter} = parseFrontmatter(content);
|
||||
|
||||
// Verify the fmg-id still matches (file might have been modified)
|
||||
if (frontmatter["fmg-id"] === fmgId || frontmatter.fmgId === fmgId) {
|
||||
return {
|
||||
path: indexedPath,
|
||||
name: indexedPath.replace(/\.md$/, "").split("/").pop(),
|
||||
content,
|
||||
frontmatter
|
||||
};
|
||||
} else {
|
||||
// Index is stale, remove the entry
|
||||
WARN && console.warn(`Index entry stale for ${fmgId}, removing`);
|
||||
delete fmgIdIndex[fmgId];
|
||||
saveFmgIdIndex();
|
||||
}
|
||||
} catch (error) {
|
||||
// File no longer exists, remove from index
|
||||
WARN && console.warn(`Indexed file not found: ${indexedPath}, removing from index`);
|
||||
delete fmgIdIndex[fmgId];
|
||||
saveFmgIdIndex();
|
||||
}
|
||||
}
|
||||
|
||||
// Not in index or index was stale, search all files
|
||||
INFO && console.log(`Searching vault for fmg-id: ${fmgId}`);
|
||||
const files = await getVaultFiles();
|
||||
const mdFiles = files.filter(f => f.endsWith(".md"));
|
||||
|
||||
for (const filePath of mdFiles) {
|
||||
try {
|
||||
const content = await getNote(filePath);
|
||||
const {frontmatter} = parseFrontmatter(content);
|
||||
|
||||
if (frontmatter["fmg-id"] === fmgId || frontmatter.fmgId === fmgId) {
|
||||
// Found it! Add to index for next time
|
||||
addToFmgIdIndex(fmgId, filePath);
|
||||
INFO && console.log(`Found note and added to index: ${fmgId} → ${filePath}`);
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
name: filePath.replace(/\.md$/, "").split("/").pop(),
|
||||
content,
|
||||
frontmatter
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
DEBUG && console.debug("Skipping file:", filePath, error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
ERROR && console.error("Failed to find note by FMG ID:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate note template for FMG element
|
||||
function generateNoteTemplate(element, type, elementId) {
|
||||
const {x, y} = element;
|
||||
const lat = pack.cells.lat?.[element.cell] || 0;
|
||||
const lon = pack.cells.lon?.[element.cell] || 0;
|
||||
|
||||
const frontmatter = {
|
||||
"fmg-id": elementId || element.id || `${type}${element.i}`,
|
||||
"fmg-type": type,
|
||||
coordinates: {x, y, lat, lon},
|
||||
tags: [type],
|
||||
created: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (element.name) {
|
||||
frontmatter.aliases = [element.name];
|
||||
}
|
||||
|
||||
const yaml = Object.entries(frontmatter)
|
||||
.map(([key, value]) => {
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
const nested = Object.entries(value)
|
||||
.map(([k, v]) => ` ${k}: ${v}`)
|
||||
.join("\n");
|
||||
return `${key}:\n${nested}`;
|
||||
} else if (Array.isArray(value)) {
|
||||
return `${key}:\n - ${value.join("\n - ")}`;
|
||||
}
|
||||
return `${key}: ${value}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const title = element.name || `${type} ${element.i}`;
|
||||
|
||||
return `---
|
||||
${yaml}
|
||||
---
|
||||
|
||||
# ${title}
|
||||
|
||||
*This note was created from Fantasy Map Generator*
|
||||
|
||||
## Description
|
||||
|
||||
Add your lore here...
|
||||
|
||||
## History
|
||||
|
||||
## Notable Features
|
||||
|
||||
## Related
|
||||
`;
|
||||
}
|
||||
|
||||
// Search notes by text query (searches in filename and frontmatter)
|
||||
async function searchNotes(query) {
|
||||
if (!query || query.trim() === "") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allFiles = await getVaultFiles();
|
||||
const searchTerm = query.toLowerCase();
|
||||
const results = [];
|
||||
|
||||
for (const filePath of allFiles) {
|
||||
const fileName = filePath.split("/").pop().replace(".md", "").toLowerCase();
|
||||
|
||||
// Check if filename matches
|
||||
if (fileName.includes(searchTerm)) {
|
||||
try {
|
||||
const content = await getNote(filePath);
|
||||
const {frontmatter} = parseFrontmatter(content);
|
||||
|
||||
results.push({
|
||||
path: filePath,
|
||||
name: filePath.split("/").pop().replace(".md", ""),
|
||||
frontmatter,
|
||||
matchType: "filename"
|
||||
});
|
||||
} catch (error) {
|
||||
WARN && console.warn(`Could not read file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// List all notes with basic info (reads frontmatter - slow for large vaults)
|
||||
async function listAllNotes() {
|
||||
const allFiles = await getVaultFiles();
|
||||
const notes = [];
|
||||
|
||||
INFO && console.log(`listAllNotes: Processing ${allFiles.length} files`);
|
||||
|
||||
for (const filePath of allFiles) {
|
||||
try {
|
||||
const content = await getNote(filePath);
|
||||
const {frontmatter} = parseFrontmatter(content);
|
||||
|
||||
notes.push({
|
||||
path: filePath,
|
||||
name: filePath.split("/").pop().replace(".md", ""),
|
||||
frontmatter,
|
||||
folder: filePath.includes("/") ? filePath.substring(0, filePath.lastIndexOf("/")) : ""
|
||||
});
|
||||
} catch (error) {
|
||||
WARN && console.warn(`Could not read file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by path
|
||||
notes.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
INFO && console.log(`listAllNotes: Returning ${notes.length} notes`);
|
||||
DEBUG && console.log("Sample note paths:", notes.slice(0, 5).map(n => n.path));
|
||||
|
||||
return notes;
|
||||
}
|
||||
|
||||
// List just file paths (fast - no content reading)
|
||||
async function listAllNotePaths() {
|
||||
const allFiles = await getVaultFiles();
|
||||
|
||||
INFO && console.log(`listAllNotePaths: Found ${allFiles.length} files`);
|
||||
|
||||
// Convert to note objects with just path and name
|
||||
const notes = allFiles.map(filePath => ({
|
||||
path: filePath,
|
||||
name: filePath.split("/").pop().replace(".md", ""),
|
||||
folder: filePath.includes("/") ? filePath.substring(0, filePath.lastIndexOf("/")) : ""
|
||||
}));
|
||||
|
||||
// Sort by path
|
||||
notes.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
return notes;
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
config,
|
||||
saveConfig,
|
||||
testConnection,
|
||||
getVaultFiles,
|
||||
clearVaultCache,
|
||||
getNote,
|
||||
updateNote,
|
||||
createNote,
|
||||
parseFrontmatter,
|
||||
findNotesByCoordinates,
|
||||
findNoteByFmgId,
|
||||
generateNoteTemplate,
|
||||
searchNotes,
|
||||
listAllNotes,
|
||||
listAllNotePaths,
|
||||
addToFmgIdIndex,
|
||||
getFromFmgIdIndex,
|
||||
buildCompleteIndex
|
||||
};
|
||||
})();
|
||||
|
||||
// Initialize on load
|
||||
ObsidianBridge.init();
|
||||
|
|
@ -167,6 +167,36 @@ 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);
|
||||
localStorage.setItem("onloadBehavior", "default");
|
||||
byId("onloadBehavior").value = "default";
|
||||
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);
|
||||
localStorage.removeItem("onloadBehavior");
|
||||
byId("onloadBehavior").value = "random";
|
||||
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"});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,26 @@
|
|||
// Assign biome and province info to existing markers, burgs, and provinces after loading a map
|
||||
window.assignBiomeAndProvinceInfo = function() {
|
||||
// Markers
|
||||
if (pack.markers && pack.cells && pack.cells.biome) {
|
||||
pack.markers.forEach(marker => {
|
||||
if (marker.cell !== undefined) {
|
||||
marker.biome = pack.cells.biome[marker.cell];
|
||||
marker.province = pack.cells.province ? pack.cells.province[marker.cell] : undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
// Burgs
|
||||
if (pack.burgs && pack.cells && pack.cells.biome) {
|
||||
pack.burgs.forEach(burg => {
|
||||
if (burg.cell !== undefined) {
|
||||
burg.biome = pack.cells.biome[burg.cell];
|
||||
burg.province = pack.cells.province ? pack.cells.province[burg.cell] : undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
// Provinces (if you want to attach biome info, though provinces are usually collections of cells)
|
||||
// You could aggregate biomes for each province if needed
|
||||
};
|
||||
"use strict";
|
||||
|
||||
window.Markers = (function () {
|
||||
|
|
@ -154,10 +177,20 @@ window.Markers = (function () {
|
|||
if (marker.cell === undefined) return;
|
||||
const i = last(pack.markers)?.i + 1 || 0;
|
||||
const [x, y] = getMarkerCoordinates(marker.cell);
|
||||
marker = {...base, x, y, ...marker, i};
|
||||
pack.markers.push(marker);
|
||||
occupied[marker.cell] = true;
|
||||
return marker;
|
||||
// Attach biome and province info
|
||||
const biome = pack.cells.biome[marker.cell];
|
||||
const province = pack.cells.province ? pack.cells.province[marker.cell] : undefined;
|
||||
// Add Obsidian note path (customize as needed)
|
||||
const obsidianNotePath = `Neblub/Orbis/Markers/${marker.type}-${marker.cell}`;
|
||||
marker = {...base, x, y, ...marker, i, biome, province, obsidianNotePath};
|
||||
// Utility to open Obsidian note for a marker
|
||||
window.openObsidianNote = function(notePath) {
|
||||
const uri = `obsidian://open?vault=Neblub&file=${encodeURIComponent(notePath)}`;
|
||||
window.open(uri, '_blank');
|
||||
};
|
||||
pack.markers.push(marker);
|
||||
occupied[marker.cell] = true;
|
||||
return marker;
|
||||
}
|
||||
|
||||
function deleteMarker(markerId) {
|
||||
|
|
|
|||
|
|
@ -486,9 +486,17 @@ function editBurg(id) {
|
|||
}
|
||||
|
||||
function editBurgLegend() {
|
||||
const id = elSelected.attr("data-id");
|
||||
const name = elSelected.text();
|
||||
editNotes("burg" + id, name);
|
||||
const id = +elSelected.attr("data-id");
|
||||
const burg = pack.burgs[id];
|
||||
|
||||
// Use Obsidian integration if available, otherwise fall back to old notes system
|
||||
if (typeof editObsidianNote !== "undefined") {
|
||||
const coordinates = {x: burg.x, y: burg.y};
|
||||
editObsidianNote("burg" + id, "burg", coordinates);
|
||||
} else {
|
||||
const name = elSelected.text();
|
||||
editNotes("burg" + id, name);
|
||||
}
|
||||
}
|
||||
|
||||
function showTemperatureGraph() {
|
||||
|
|
|
|||
|
|
@ -481,7 +481,7 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) {
|
|||
}
|
||||
|
||||
function downloadBurgsData() {
|
||||
let data = `Id,Burg,Province,Province Full Name,State,State Full Name,Culture,Religion,Population,X,Y,Latitude,Longitude,Elevation (${heightUnit.value}),Temperature,Temperature likeness,Capital,Port,Citadel,Walls,Plaza,Temple,Shanty Town,Emblem,City Generator Link\n`; // headers
|
||||
let data = `Id,Burg,Province,Province Full Name,State,State Full Name,Culture,Religion,Population,X,Y,Latitude,Longitude,Elevation (${heightUnit.value}),Temperature,Temperature likeness,Biome,Province Id,Capital,Port,Citadel,Walls,Plaza,Temple,Shanty Town,Emblem,City Generator Link\n`; // headers
|
||||
const valid = pack.burgs.filter(b => b.i && !b.removed); // all valid burgs
|
||||
|
||||
valid.forEach(b => {
|
||||
|
|
@ -506,6 +506,11 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) {
|
|||
data += convertTemperature(temperature) + ",";
|
||||
data += getTemperatureLikeness(temperature) + ",";
|
||||
|
||||
// add biome and province id
|
||||
const biomeName = b.biome !== undefined ? window.Biomes.getDefault().name[b.biome] : "";
|
||||
data += biomeName + ",";
|
||||
data += b.province !== undefined ? b.province + "," : ",";
|
||||
|
||||
// add status data
|
||||
data += b.capital ? "capital," : ",";
|
||||
data += b.port ? "port," : ",";
|
||||
|
|
|
|||
|
|
@ -221,7 +221,14 @@ function editMarker(markerI) {
|
|||
|
||||
function editMarkerLegend() {
|
||||
const id = element.id;
|
||||
editNotes(id, id);
|
||||
|
||||
// Use Obsidian integration if available, otherwise fall back to old notes system
|
||||
if (typeof editObsidianNote !== "undefined" && marker) {
|
||||
const coordinates = {x: marker.x, y: marker.y};
|
||||
editObsidianNote(id, "marker", coordinates);
|
||||
} else {
|
||||
editNotes(id, id);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMarkerLock() {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,9 @@ function overviewMarkers() {
|
|||
|
||||
function addLines() {
|
||||
const lines = pack.markers
|
||||
.map(({i, type, icon, pinned, lock}) => {
|
||||
.map(({i, type, icon, pinned, lock, biome, province}) => {
|
||||
const biomeName = biome !== undefined ? window.Biomes.getDefault().name[biome] : "";
|
||||
const provinceName = province !== undefined && pack.provinces[province] ? pack.provinces[province].name : "";
|
||||
return /* html */ `
|
||||
<div class="states" data-i=${i} data-type="${type}">
|
||||
${
|
||||
|
|
@ -77,6 +79,8 @@ function overviewMarkers() {
|
|||
: `<span data-tip="Marker icon" style="width:1.2em">${icon}</span>`
|
||||
}
|
||||
<div data-tip="Marker type" style="width:10em">${type}</div>
|
||||
<div data-tip="Biome" style="width:10em">${biomeName}</div>
|
||||
<div data-tip="Province" style="width:10em">${provinceName}</div>
|
||||
<span style="padding-right:.1em" data-tip="Edit marker" class="icon-pencil"></span>
|
||||
<span style="padding-right:.1em" data-tip="Focus on marker position" class="icon-dot-circled pointer"></span>
|
||||
<span style="padding-right:.1em" data-tip="Pin marker (display only pinned markers)" class="icon-pin ${
|
||||
|
|
@ -209,11 +213,11 @@ function overviewMarkers() {
|
|||
}
|
||||
|
||||
function exportMarkers() {
|
||||
const headers = "Id,Type,Icon,Name,Note,X,Y,Latitude,Longitude\n";
|
||||
const headers = "Id,Type,Icon,Name,Note,X,Y,Latitude,Longitude,Biome,Province\n";
|
||||
const quote = s => '"' + s.replaceAll('"', '""') + '"';
|
||||
|
||||
const body = pack.markers.map(marker => {
|
||||
const {i, type, icon, x, y} = marker;
|
||||
const {i, type, icon, x, y, biome, province} = marker;
|
||||
|
||||
const note = notes.find(note => note.id === "marker" + i);
|
||||
const name = note ? quote(note.name) : "Unknown";
|
||||
|
|
@ -221,8 +225,10 @@ function overviewMarkers() {
|
|||
|
||||
const lat = getLatitude(y, 2);
|
||||
const lon = getLongitude(x, 2);
|
||||
const biomeName = biome !== undefined ? window.Biomes.getDefault().name[biome] : "";
|
||||
const provinceName = province !== undefined && pack.provinces[province] ? pack.provinces[province].name : "";
|
||||
|
||||
return [i, type, icon, name, legend, x, y, lat, lon].join(",");
|
||||
return [i, type, icon, name, legend, x, y, lat, lon, biomeName, provinceName].join(",");
|
||||
});
|
||||
|
||||
const data = headers + body.join("\n");
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
806
modules/ui/obsidian-notes-editor.js
Normal file
806
modules/ui/obsidian-notes-editor.js
Normal file
|
|
@ -0,0 +1,806 @@
|
|||
"use strict";
|
||||
|
||||
// Modern Markdown Notes Editor with Obsidian Integration
|
||||
|
||||
function editObsidianNote(elementId, elementType, coordinates) {
|
||||
const {x, y} = coordinates;
|
||||
|
||||
// Show choice dialog: automatic search or manual browse
|
||||
showSearchMethodDialog(elementId, elementType, coordinates);
|
||||
}
|
||||
|
||||
function showSearchMethodDialog(elementId, elementType, coordinates) {
|
||||
const element = getElementData(elementId, elementType);
|
||||
const elementName = element.name || elementId;
|
||||
|
||||
alertMessage.innerHTML = `
|
||||
<div style="padding: 1em;">
|
||||
<p style="margin-bottom: 1em;"><strong>${elementName}</strong></p>
|
||||
<p style="margin-bottom: 1.5em; color: #666;">How would you like to find the note for this ${elementType}?</p>
|
||||
|
||||
<div style="margin: 1em 0; padding: 12px; background: #f0f8ff; border: 1px solid #0066cc; border-radius: 4px;">
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">🔍 Automatic Search</div>
|
||||
<div style="font-size: 0.9em; color: #666;">Search by linked ID or nearby coordinates</div>
|
||||
</div>
|
||||
|
||||
<div style="margin: 1em 0; padding: 12px; background: #fff8e1; border: 1px solid #ffa000; border-radius: 4px;">
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">📁 Browse Manually</div>
|
||||
<div style="font-size: 0.9em; color: #666;">Browse your vault's folder tree</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$("#alert").dialog({
|
||||
title: "Select Note",
|
||||
width: "450px",
|
||||
buttons: {
|
||||
"Search": function () {
|
||||
$(this).dialog("close");
|
||||
// Show loading and do automatic search
|
||||
showLoadingDialog();
|
||||
findOrCreateNote(elementId, elementType, coordinates)
|
||||
.then(noteData => {
|
||||
showMarkdownEditor(noteData, elementType, elementId, coordinates);
|
||||
})
|
||||
.catch(error => {
|
||||
ERROR && console.error("Failed to load note:", error);
|
||||
tip("Failed to load Obsidian note: " + error.message, true, "error", 5000);
|
||||
closeDialogs("#obsidianNoteLoading");
|
||||
});
|
||||
},
|
||||
"Browse": function () {
|
||||
$(this).dialog("close");
|
||||
promptCreateNewNote(elementId, elementType, coordinates)
|
||||
.then(noteData => {
|
||||
showMarkdownEditor(noteData, elementType, elementId, coordinates);
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.message !== "Cancelled") {
|
||||
ERROR && console.error("Failed to load note:", error);
|
||||
tip("Failed to load Obsidian note: " + error.message, true, "error", 5000);
|
||||
}
|
||||
});
|
||||
},
|
||||
"Cancel": function () {
|
||||
$(this).dialog("close");
|
||||
}
|
||||
},
|
||||
position: {my: "center", at: "center", of: "svg"}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
closeDialogs("#obsidianNoteLoading");
|
||||
// Show dialog with option to open linked note or choose different one
|
||||
return await showLinkedNoteDialog(note, elementId, elementType, coordinates);
|
||||
}
|
||||
|
||||
// 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 - show dialog with option to use it or choose different one
|
||||
const match = matches[0];
|
||||
const content = await ObsidianBridge.getNote(match.path);
|
||||
const noteData = {
|
||||
path: match.path,
|
||||
name: match.name,
|
||||
content,
|
||||
frontmatter: match.frontmatter
|
||||
};
|
||||
return await showSingleMatchDialog(noteData, elementId, elementType, coordinates);
|
||||
}
|
||||
|
||||
// Multiple matches - show selection dialog
|
||||
return await showNoteSelectionDialog(matches, elementId, elementType, coordinates);
|
||||
}
|
||||
|
||||
async function showLinkedNoteDialog(note, elementId, elementType, coordinates) {
|
||||
return new Promise((resolve, reject) => {
|
||||
alertMessage.innerHTML = `
|
||||
<div style="padding: 1em;">
|
||||
<p style="margin-bottom: 1em;"><strong>✓ Found linked note:</strong></p>
|
||||
<div style="padding: 12px; background: #f0f8ff; border: 1px solid #0066cc; border-radius: 4px; margin-bottom: 1.5em;">
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">${note.name}</div>
|
||||
<div style="font-size: 0.9em; color: #666;">Path: ${note.path}</div>
|
||||
</div>
|
||||
<p style="font-size: 0.9em; color: #666;">This element is already linked to the note above. You can open it or choose a different note.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$("#alert").dialog({
|
||||
title: "Linked Note Found",
|
||||
width: "500px",
|
||||
buttons: {
|
||||
"Open Linked Note": function () {
|
||||
$(this).dialog("close");
|
||||
resolve(note);
|
||||
},
|
||||
"Choose Different Note": async function () {
|
||||
$(this).dialog("close");
|
||||
try {
|
||||
const differentNote = await promptCreateNewNote(elementId, elementType, coordinates);
|
||||
resolve(differentNote);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
Cancel: function () {
|
||||
$(this).dialog("close");
|
||||
reject(new Error("Cancelled"));
|
||||
}
|
||||
},
|
||||
position: {my: "center", at: "center", of: "svg"}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function showSingleMatchDialog(note, elementId, elementType, coordinates) {
|
||||
return new Promise((resolve, reject) => {
|
||||
alertMessage.innerHTML = `
|
||||
<div style="padding: 1em;">
|
||||
<p style="margin-bottom: 1em;"><strong>✓ Found note by coordinates:</strong></p>
|
||||
<div style="padding: 12px; background: #f0fff0; border: 1px solid #00aa00; border-radius: 4px; margin-bottom: 1.5em;">
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">${note.name}</div>
|
||||
<div style="font-size: 0.9em; color: #666;">Path: ${note.path}</div>
|
||||
</div>
|
||||
<p style="font-size: 0.9em; color: #666;">Found a note near this location. You can use it or browse for a different one.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$("#alert").dialog({
|
||||
title: "Note Found Nearby",
|
||||
width: "500px",
|
||||
buttons: {
|
||||
"Use This Note": function () {
|
||||
$(this).dialog("close");
|
||||
resolve(note);
|
||||
},
|
||||
"Browse/Search": async function () {
|
||||
$(this).dialog("close");
|
||||
try {
|
||||
const differentNote = await promptCreateNewNote(elementId, elementType, coordinates);
|
||||
resolve(differentNote);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
Cancel: function () {
|
||||
$(this).dialog("close");
|
||||
reject(new Error("Cancelled"));
|
||||
}
|
||||
},
|
||||
position: {my: "center", at: "center", of: "svg"}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
// Build context info for the element
|
||||
let contextInfo = "";
|
||||
if (element.state) {
|
||||
contextInfo += `<div style="color: #666; font-size: 0.9em;">State: ${element.state}</div>`;
|
||||
}
|
||||
if (element.province) {
|
||||
contextInfo += `<div style="color: #666; font-size: 0.9em;">Province: ${element.province}</div>`;
|
||||
}
|
||||
|
||||
// Pre-fill search with state or element name
|
||||
const defaultSearch = element.state || element.name || "";
|
||||
|
||||
alertMessage.innerHTML = `
|
||||
<div style="margin-bottom: 1.5em;">
|
||||
<p><strong>${element.name || elementId}</strong></p>
|
||||
${contextInfo}
|
||||
<p style="margin-top: 0.5em;">No matching notes found by coordinates.</p>
|
||||
</div>
|
||||
|
||||
<div style="margin: 1.5em 0; padding: 1em; background: #f5f5f5; border-radius: 4px;">
|
||||
<label for="obsidianSearch" style="display: block; margin-bottom: 0.5em; font-weight: bold;">Search your vault:</label>
|
||||
<input id="obsidianSearch" type="text" placeholder="Type to search..." value="${defaultSearch}" style="width: 100%; padding: 8px; font-size: 1em; margin-bottom: 8px;"/>
|
||||
<button id="obsidianSearchBtn" style="padding: 6px 12px;">Search</button>
|
||||
<button id="obsidianBrowseBtn" style="padding: 6px 12px; margin-left: 8px;">Browse All Notes</button>
|
||||
<div id="obsidianSearchResults" style="margin-top: 1em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1.5em; padding-top: 1.5em; border-top: 1px solid #ddd;">
|
||||
<p style="font-weight: bold; margin-bottom: 1em;">Or create a new note:</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>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$("#alert").dialog({
|
||||
title: "Find or Create Note",
|
||||
width: "600px",
|
||||
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, elementId);
|
||||
await ObsidianBridge.createNote(notePath, template);
|
||||
|
||||
const {frontmatter} = ObsidianBridge.parseFrontmatter(template);
|
||||
|
||||
// Add to FMG ID index for instant future lookups
|
||||
const fmgId = frontmatter["fmg-id"] || frontmatter.fmgId;
|
||||
if (fmgId) {
|
||||
ObsidianBridge.addToFmgIdIndex(fmgId, notePath);
|
||||
INFO && console.log(`New note added to index: ${fmgId} → ${notePath}`);
|
||||
}
|
||||
|
||||
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"}
|
||||
});
|
||||
|
||||
// Add event handlers for search and browse
|
||||
const searchBtn = byId("obsidianSearchBtn");
|
||||
const browseBtn = byId("obsidianBrowseBtn");
|
||||
const searchInput = byId("obsidianSearch");
|
||||
const resultsDiv = byId("obsidianSearchResults");
|
||||
|
||||
const performSearch = async () => {
|
||||
const query = searchInput.value.trim();
|
||||
if (!query) {
|
||||
resultsDiv.innerHTML = "<p style='color: #999;'>Enter a search term</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = "<p>Searching...</p>";
|
||||
|
||||
try {
|
||||
const results = await ObsidianBridge.searchNotes(query);
|
||||
|
||||
if (results.length === 0) {
|
||||
resultsDiv.innerHTML = "<p style='color: #999;'>No matching notes found</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = results
|
||||
.map(
|
||||
(note, index) => `
|
||||
<div class="search-result" data-index="${index}" style="
|
||||
padding: 8px;
|
||||
margin: 4px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: white;
|
||||
" onmouseover="this.style.background='#e8e8e8'" onmouseout="this.style.background='white'">
|
||||
<div style="font-weight: bold;">${note.name}</div>
|
||||
<div style="font-size: 0.85em; color: #666;">${note.path}</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
// Add click handlers
|
||||
document.querySelectorAll(".search-result").forEach((el, index) => {
|
||||
el.addEventListener("click", async () => {
|
||||
$("#alert").dialog("close");
|
||||
try {
|
||||
const note = results[index];
|
||||
const content = await ObsidianBridge.getNote(note.path);
|
||||
resolve({
|
||||
path: note.path,
|
||||
name: note.name,
|
||||
content,
|
||||
frontmatter: note.frontmatter
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
resultsDiv.innerHTML = `<p style='color: red;'>Search failed: ${error.message}</p>`;
|
||||
}
|
||||
};
|
||||
|
||||
const showBrowse = async () => {
|
||||
resultsDiv.innerHTML = "<p>Loading file list...</p>";
|
||||
|
||||
try {
|
||||
// Use fast path-only listing (doesn't read file contents)
|
||||
const allNotes = await ObsidianBridge.listAllNotePaths();
|
||||
|
||||
if (allNotes.length === 0) {
|
||||
resultsDiv.innerHTML = "<p style='color: #999;'>No notes in vault</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
INFO && console.log(`Displaying ${allNotes.length} notes in folder tree`);
|
||||
|
||||
// Build folder tree
|
||||
const tree = buildFolderTree(allNotes);
|
||||
resultsDiv.innerHTML = renderFolderTree(tree, allNotes);
|
||||
|
||||
// Add click handlers to files
|
||||
document.querySelectorAll(".tree-file").forEach(el => {
|
||||
el.addEventListener("click", async () => {
|
||||
const index = parseInt(el.dataset.index);
|
||||
$("#alert").dialog("close");
|
||||
try {
|
||||
const note = allNotes[index];
|
||||
// Read the file content only when clicked
|
||||
const content = await ObsidianBridge.getNote(note.path);
|
||||
const {frontmatter} = ObsidianBridge.parseFrontmatter(content);
|
||||
|
||||
resolve({
|
||||
path: note.path,
|
||||
name: note.name,
|
||||
content,
|
||||
frontmatter
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add click handlers to folder toggles
|
||||
document.querySelectorAll(".tree-folder-toggle").forEach(el => {
|
||||
el.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
const folder = el.parentElement.nextElementSibling;
|
||||
const isCollapsed = folder.style.display === "none";
|
||||
folder.style.display = isCollapsed ? "block" : "none";
|
||||
el.textContent = isCollapsed ? "▼" : "▶";
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
resultsDiv.innerHTML = `<p style='color: red;'>Failed to load notes: ${error.message}</p>`;
|
||||
}
|
||||
};
|
||||
|
||||
searchBtn.addEventListener("click", performSearch);
|
||||
browseBtn.addEventListener("click", showBrowse);
|
||||
searchInput.addEventListener("keypress", e => {
|
||||
if (e.key === "Enter") performSearch();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildFolderTree(notes) {
|
||||
const root = {folders: {}, files: []};
|
||||
|
||||
INFO && console.log(`buildFolderTree: Processing ${notes.length} notes`);
|
||||
|
||||
notes.forEach((note, index) => {
|
||||
const parts = note.path.split("/");
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
DEBUG && console.log(`Processing note ${index}: ${note.path} (${parts.length} parts)`);
|
||||
|
||||
if (parts.length === 1) {
|
||||
// Root level file
|
||||
root.files.push({name: fileName, index, path: note.path});
|
||||
DEBUG && console.log(` -> Added to root files: ${fileName}`);
|
||||
} else {
|
||||
// Navigate/create folder structure
|
||||
let current = root;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const folderName = parts[i];
|
||||
if (!current.folders[folderName]) {
|
||||
current.folders[folderName] = {folders: {}, files: []};
|
||||
DEBUG && console.log(` -> Created folder: ${folderName}`);
|
||||
}
|
||||
current = current.folders[folderName];
|
||||
}
|
||||
// Add file to final folder
|
||||
current.files.push({name: fileName, index, path: note.path});
|
||||
DEBUG && console.log(` -> Added to folder: ${fileName}`);
|
||||
}
|
||||
});
|
||||
|
||||
INFO && console.log("Folder tree structure:", root);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function renderFolderTree(node, allNotes, indent = 0) {
|
||||
let html = "";
|
||||
const indentPx = indent * 20;
|
||||
|
||||
// Render folders
|
||||
for (const [folderName, folderData] of Object.entries(node.folders || {})) {
|
||||
html += `
|
||||
<div style="margin-left: ${indentPx}px;">
|
||||
<div style="padding: 4px; cursor: pointer; user-select: none;">
|
||||
<span class="tree-folder-toggle" style="display: inline-block; width: 16px; font-size: 12px;">▼</span>
|
||||
<span style="font-weight: bold;">📁 ${folderName}</span>
|
||||
</div>
|
||||
<div class="tree-folder-content" style="display: block;">
|
||||
${renderFolderTree(folderData, allNotes, indent + 1)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render files in current folder
|
||||
html += renderFiles(node.files || [], indent);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderFiles(files, indent) {
|
||||
const indentPx = indent * 20;
|
||||
return files
|
||||
.map(
|
||||
file => `
|
||||
<div class="tree-file" data-index="${file.index}" style="
|
||||
margin-left: ${indentPx}px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
" onmouseover="this.style.background='#e8e8e8'" onmouseout="this.style.background='transparent'">
|
||||
<span style="font-size: 12px;">📄</span> ${file.name.replace(".md", "")}
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function getElementData(elementId, elementType) {
|
||||
// Extract element data based on type
|
||||
if (elementType === "burg") {
|
||||
const burgId = parseInt(elementId.replace("burg", ""));
|
||||
const burg = pack.burgs[burgId];
|
||||
|
||||
// Enhance with state and province names
|
||||
const stateId = burg.state;
|
||||
const provinceId = burg.province;
|
||||
|
||||
return {
|
||||
...burg,
|
||||
state: stateId && pack.states[stateId] ? pack.states[stateId].name : null,
|
||||
province: provinceId && pack.provinces[provinceId] ? pack.provinces[provinceId].name : null
|
||||
};
|
||||
} else if (elementType === "marker") {
|
||||
const markerId = parseInt(elementId.replace("marker", ""));
|
||||
const marker = pack.markers[markerId];
|
||||
|
||||
// Enhance with state and province if marker has a cell
|
||||
if (marker.cell) {
|
||||
const cell = pack.cells;
|
||||
const stateId = cell.state[marker.cell];
|
||||
const provinceId = cell.province[marker.cell];
|
||||
|
||||
return {
|
||||
...marker,
|
||||
state: stateId && pack.states[stateId] ? pack.states[stateId].name : null,
|
||||
province: provinceId && pack.provinces[provinceId] ? pack.provinces[provinceId].name : null
|
||||
};
|
||||
}
|
||||
|
||||
return marker;
|
||||
} 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, elementId, coordinates) {
|
||||
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 and FMG element info
|
||||
showMarkdownEditor.currentNote = noteData;
|
||||
showMarkdownEditor.originalContent = content;
|
||||
showMarkdownEditor.elementId = elementId;
|
||||
showMarkdownEditor.elementType = elementType;
|
||||
showMarkdownEditor.coordinates = coordinates;
|
||||
|
||||
$("#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;
|
||||
}
|
||||
|
||||
let content = byId("obsidianMarkdownEditor").value;
|
||||
const {path} = showMarkdownEditor.currentNote;
|
||||
const elementId = showMarkdownEditor.elementId;
|
||||
const coordinates = showMarkdownEditor.coordinates;
|
||||
|
||||
// Update/add frontmatter with FMG ID and coordinates
|
||||
if (elementId && coordinates) {
|
||||
content = updateFrontmatterWithFmgData(content, elementId, coordinates);
|
||||
}
|
||||
|
||||
try {
|
||||
await ObsidianBridge.updateNote(path, content);
|
||||
|
||||
// Update the FMG ID index if this note has an fmg-id
|
||||
if (elementId) {
|
||||
const {frontmatter} = ObsidianBridge.parseFrontmatter(content);
|
||||
const fmgId = frontmatter["fmg-id"] || frontmatter.fmgId;
|
||||
if (fmgId) {
|
||||
// Add to index using internal method
|
||||
ObsidianBridge.addToFmgIdIndex(fmgId, path);
|
||||
}
|
||||
}
|
||||
|
||||
showMarkdownEditor.originalContent = content;
|
||||
// Update the editor to show the new frontmatter
|
||||
byId("obsidianMarkdownEditor").value = content;
|
||||
tip("Note saved to Obsidian vault (linked to FMG element)", true, "success", 3000);
|
||||
} catch (error) {
|
||||
ERROR && console.error("Failed to save note:", error);
|
||||
tip("Failed to save note: " + error.message, true, "error", 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFrontmatterWithFmgData(content, elementId, coordinates) {
|
||||
const {x, y} = coordinates;
|
||||
const {frontmatter, content: bodyContent} = ObsidianBridge.parseFrontmatter(content);
|
||||
|
||||
// Update frontmatter with FMG data
|
||||
frontmatter["fmg-id"] = elementId;
|
||||
frontmatter["x"] = Math.round(x * 100) / 100;
|
||||
frontmatter["y"] = Math.round(y * 100) / 100;
|
||||
|
||||
// Rebuild frontmatter
|
||||
let frontmatterLines = ["---"];
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
// Handle nested objects
|
||||
frontmatterLines.push(`${key}:`);
|
||||
for (const [nestedKey, nestedValue] of Object.entries(value)) {
|
||||
frontmatterLines.push(` ${nestedKey}: ${nestedValue}`);
|
||||
}
|
||||
} else {
|
||||
frontmatterLines.push(`${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
frontmatterLines.push("---");
|
||||
|
||||
return frontmatterLines.join("\n") + "\n" + bodyContent;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
0
run_python_server.sh
Normal file → Executable file
0
run_python_server.sh
Normal file → Executable file
|
|
@ -13,7 +13,9 @@
|
|||
* 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");
|
||||
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue