From 1c4b6b27749d02ba5e0d916d304051d57bd9a947 Mon Sep 17 00:00:00 2001 From: geoffreysflaminglasersword Date: Mon, 26 Jul 2021 02:03:54 -0500 Subject: [PATCH 1/6] Added image location detection functionality --- .gitignore | 2 ++ package.json | 77 ++++++++++++++++++++++++------------------------ rollup.config.js | 54 ++++++++++++++++----------------- src/mapView.ts | 53 +++++++++++++++------------------ src/markers.ts | 32 ++++++++++++++++---- src/settings.ts | 22 +++++++++----- 6 files changed, 132 insertions(+), 108 deletions(-) diff --git a/.gitignore b/.gitignore index 4151650..0097734 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ main.js # obsidian data.json + +.history/** \ No newline at end of file diff --git a/package.json b/package.json index 2a4eee5..5255027 100644 --- a/package.json +++ b/package.json @@ -1,39 +1,40 @@ { - "name": "obsidian-map-view", - "version": "0.0.8", - "description": "An interactive map view for Obsidian.md", - "main": "main.js", - "scripts": { - "dev": "rollup --config rollup.config.js -w", - "build": "rollup --config rollup.config.js --environment BUILD:production" - }, - "keywords": [], - "author": "", - "license": "MIT", - "devDependencies": { - "@rollup/plugin-commonjs": "^18.0.0", - "@rollup/plugin-image": "^2.0.6", - "@rollup/plugin-node-resolve": "^11.2.1", - "@rollup/plugin-typescript": "^8.2.1", - "@types/geojson": "^7946.0.7", - "@types/leaflet": "^1.7.2", - "@types/node": "^14.14.37", - "obsidian": "^0.12.5", - "postcss-less": "^4.0.1", - "postcss-url": "^10.1.3", - "rollup": "^2.32.1", - "rollup-plugin-copy": "^3.4.0", - "rollup-plugin-postcss": "^4.0.0", - "tslib": "^2.2.0", - "typescript": "^4.2.4" - }, - "dependencies": { - "@fortawesome/fontawesome-free": "^5.15.3", - "leaflet": "^1.7.1", - "leaflet-extra-markers": "github:coryasilva/Leaflet.ExtraMarkers", - "leaflet-fullscreen": "^1.0.2", - "leaflet-geosearch": "^3.3.2", - "moment": "^2.29.1", - "open": "^8.2.1" - } -} + "name": "obsidian-map-view", + "version": "0.0.8", + "description": "An interactive map view for Obsidian.md", + "main": "main.js", + "scripts": { + "dev": "rollup --config rollup.config.js -w", + "build": "rollup --config rollup.config.js --environment BUILD:production" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@rollup/plugin-commonjs": "^18.0.0", + "@rollup/plugin-image": "^2.0.6", + "@rollup/plugin-node-resolve": "^11.2.1", + "@rollup/plugin-typescript": "^8.2.1", + "@types/geojson": "^7946.0.7", + "@types/leaflet": "^1.7.2", + "@types/node": "^14.14.37", + "obsidian": "^0.12.5", + "postcss-less": "^4.0.1", + "postcss-url": "^10.1.3", + "rollup": "^2.32.1", + "rollup-plugin-copy": "^3.4.0", + "rollup-plugin-postcss": "^4.0.0", + "tslib": "^2.2.0", + "typescript": "^4.2.4" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^5.15.3", + "exifr": "^7.1.2", + "leaflet": "^1.7.1", + "leaflet-extra-markers": "github:coryasilva/Leaflet.ExtraMarkers", + "leaflet-fullscreen": "^1.0.2", + "leaflet-geosearch": "^3.3.2", + "moment": "^2.29.1", + "open": "^8.2.1" + } +} \ No newline at end of file diff --git a/rollup.config.js b/rollup.config.js index 9b55098..20d7902 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,39 +1,39 @@ -import typescript from '@rollup/plugin-typescript'; -import {nodeResolve} from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; +import copy from 'rollup-plugin-copy'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; import postcss from 'rollup-plugin-postcss'; import postcss_url from 'postcss-url'; -import copy from 'rollup-plugin-copy'; +import typescript from '@rollup/plugin-typescript'; const isProd = (process.env.BUILD === 'production'); -const banner = -`/* +const banner = + `/* THIS IS A GENERATED/BUNDLED FILE BY ROLLUP if you want to view the source visit the plugins github repository */ `; export default { - input: 'src/main.ts', - output: { - dir: './dist', - sourcemap: isProd ? false : 'inline', - sourcemapExcludeSources: isProd, - format: 'cjs', - exports: 'default', - banner, - }, - external: ['obsidian'], - plugins: [ - typescript(), - nodeResolve({browser: true}), - commonjs(), - postcss({ extensions: ['.css'], plugins: [postcss_url({url: 'inline'})] }), - copy({ - targets: [ - { src: './manifest.json', dest: 'dist' } - ] - }) - ] -}; + input: 'src/main.ts', + output: { + dir: './dist', + sourcemap: isProd ? false : 'inline', + sourcemapExcludeSources: isProd, + format: 'cjs', + exports: 'default', + banner, + }, + external: ['obsidian'], + plugins: [ + typescript(), + nodeResolve({ browser: true }), + commonjs(), + postcss({ extensions: ['.css'], plugins: [postcss_url({ url: 'inline' })] }), + copy({ + targets: [ + { src: './manifest.json', dest: 'dist' } + ] + }) + ] +}; \ No newline at end of file diff --git a/src/mapView.ts b/src/mapView.ts index b4ab915..6e64c88 100644 --- a/src/mapView.ts +++ b/src/mapView.ts @@ -9,7 +9,7 @@ import { GeoSearchControl, OpenStreetMapProvider } from 'leaflet-geosearch'; import 'leaflet-geosearch/dist/geosearch.css'; import * as consts from 'src/consts'; -import { PluginSettings, DEFAULT_SETTINGS } from 'src/settings'; +import { PluginSettings, DEFAULT_SETTINGS, isImage } from 'src/settings'; import { MarkersMap, FileMarker, buildMarkers, getIconFromOptions, buildAndAppendFileMarkers } from 'src/markers'; import MapViewPlugin from 'src/main'; import * as utils from 'src/utils'; @@ -19,7 +19,7 @@ type MapState = { mapCenter: leaflet.LatLng; tags: string[]; version: number; -} +}; export class MapView extends ItemView { private settings: PluginSettings; @@ -57,10 +57,10 @@ export class MapView extends ItemView { state.version = 100; await this.updateMapToState(state); } - } + }; this.getState = (): MapState => { return this.state; - } + }; this.app.vault.on('delete', file => this.updateMarkersWithRelationToFile(file.path, null, true)); this.app.vault.on('rename', (file, oldPath) => this.updateMarkersWithRelationToFile(oldPath, file, true)); @@ -131,7 +131,7 @@ export class MapView extends ItemView { }); this.contentEl.style.padding = '0px 0px'; this.contentEl.append(controlsDiv); - this.display.mapDiv = createDiv({cls: 'map'}, (el: HTMLDivElement) => { + this.display.mapDiv = createDiv({ cls: 'map' }, (el: HTMLDivElement) => { el.style.zIndex = '1'; el.style.width = '100%'; el.style.height = '100%'; @@ -161,7 +161,8 @@ export class MapView extends ItemView { zoom: 13, zoomControl: false, worldCopyJump: true, - maxBoundsViscosity: 1.0}); + maxBoundsViscosity: 1.0 + }); leaflet.control.zoom({ position: 'topright' }).addTo(this.display.map); @@ -169,7 +170,7 @@ export class MapView extends ItemView { '© OpenStreetMap contributors' : ''; this.display.map.addLayer(new leaflet.TileLayer(this.settings.tilesUrl, { maxZoom: 20, - subdomains:['mt0','mt1','mt2','mt3'], + subdomains: ['mt0', 'mt1', 'mt2', 'mt3'], attribution: attribution, className: this.settings.darkMode ? "dark-mode" : "" })); @@ -179,7 +180,8 @@ export class MapView extends ItemView { marker: { icon: getIconFromOptions(consts.SEARCH_RESULT_MARKER as leaflet.BaseIconOptions) }, - style: 'button'}); + style: 'button' + }); this.display.map.addControl(searchControl); this.display.map.on('zoomend', (event: leaflet.LeafletEvent) => { this.state.mapZoom = this.display.map.getZoom(); @@ -199,7 +201,7 @@ export class MapView extends ItemView { newFileName, location, this.settings.newNoteTemplate); this.goToFile(file, ev.ctrlKey); }); - }) + }); mapPopup.addItem((item: MenuItem) => { const location = `${event.latlng.lat},${event.latlng.lng}`; item.setTitle('New multi-location note'); @@ -209,7 +211,7 @@ export class MapView extends ItemView { newFileName, location, this.settings.newNoteTemplate); this.goToFile(file, ev.ctrlKey); }); - }) + }); mapPopup.addItem((item: MenuItem) => { const location = `${event.latlng.lat},${event.latlng.lng}`; item.setTitle(`Copy location as inline`); @@ -243,14 +245,14 @@ export class MapView extends ItemView { await that.updateMapToState(this.defaultState, !this.settings.defaultZoom); if (this.onAfterOpen != null) this.onAfterOpen(this.display.map, this.display.markers); - }) + }); } // Updates the map to the given state and then sets the state accordingly, but only if the given state version // is not lower than the current state version (so concurrent async updates always keep the latest one) async updateMapToState(state: MapState, autoFit: boolean = false) { - const files = this.getFileListByQuery(state.tags); + const files = await this.getFileListByQuery(state.tags); let newMarkers = await buildMarkers(files, this.settings, this.app); if (state.version < this.state.version) { // If the state we were asked to update is old (e.g. because while we were building markers a newer instance @@ -267,22 +269,13 @@ export class MapView extends ItemView { this.autoFitMapToMarkers(); } - getFileListByQuery(tags: string[]): TFile[] { + async getFileListByQuery(tags: string[]) { let results: TFile[] = []; const allFiles = this.app.vault.getFiles(); for (const file of allFiles) { - var match = true; - if (tags && tags.length > 0) { - // A tags query exist, file defaults to non-matching and we'll add it if it has one of the tags - match = false; - const fileCache = this.app.metadataCache.getFileCache(file); - if (fileCache && fileCache.tags) { - const tagsMatch = fileCache.tags.some(tagInFile => tags.indexOf(tagInFile.tag) > -1); - if (tagsMatch) - match = true; - } - } - if (match) + // only show images when not filtering by tag, currently not a clean way of adding tags to images + if (!tags?.length && isImage(file)) results.push(file); + else if (!tags?.length || this.app.metadataCache.getFileCache(file)?.tags?.some(t => tags.includes(t.tag))) results.push(file); } return results; @@ -320,7 +313,7 @@ export class MapView extends ItemView { }); mapPopup.showAtPosition(ev); ev.stopPropagation(); - }) + }); newMarkersMap.set(marker.id, marker); } } @@ -375,7 +368,7 @@ export class MapView extends ItemView { if (fileLocation) { let pos = editor.offsetToPos(fileLocation); if (highlight) { - editor.setSelection({ch: 0, line: pos.line}, {ch: 1000, line: pos.line}); + editor.setSelection({ ch: 0, line: pos.line }, { ch: 1000, line: pos.line }); } else { editor.setCursor(pos); editor.refresh(); @@ -390,7 +383,7 @@ export class MapView extends ItemView { return this.goToFile(marker.file, useCtrlKeyBehavior, marker.fileLocation, highlight); } - getAllTagNames() : string[] { + getAllTagNames(): string[] { let tags: string[] = []; const allFiles = this.app.vault.getFiles(); for (const file of allFiles) { @@ -404,7 +397,7 @@ export class MapView extends ItemView { return tags; } - getEditor() : Editor { + getEditor(): Editor { let view = this.app.workspace.getActiveViewOfType(MarkdownView); if (view) return view.editor; @@ -420,7 +413,7 @@ export class MapView extends ItemView { newMarkers.push(fileMarker); } if (fileAddedOrChanged && fileAddedOrChanged instanceof TFile) - await buildAndAppendFileMarkers(newMarkers, fileAddedOrChanged, this.settings, this.app) + await buildAndAppendFileMarkers(newMarkers, fileAddedOrChanged, this.settings, this.app); this.updateMapMarkers(newMarkers); } diff --git a/src/markers.ts b/src/markers.ts index d6779fc..93aebe5 100644 --- a/src/markers.ts +++ b/src/markers.ts @@ -6,8 +6,9 @@ import 'leaflet-extra-markers/dist/css/leaflet.extra-markers.min.css'; // @ts-ignore let localL = L; -import { PluginSettings } from 'src/settings'; +import { isImage, PluginSettings } from 'src/settings'; import * as consts from 'src/consts'; +import exifr from "exifr"; type MarkerId = string; @@ -40,7 +41,7 @@ export class FileMarker { this.icon?.options?.shape === other.icon?.options?.shape; } - generateId() : MarkerId { + generateId(): MarkerId { return this.file.name + this.location.lat.toString() + this.location.lng.toString(); } } @@ -48,6 +49,8 @@ export class FileMarker { export type MarkersMap = Map; export async function buildAndAppendFileMarkers(mapToAppendTo: FileMarker[], file: TFile, settings: PluginSettings, app: App, skipMetadata?: boolean) { + if (isImage(file)) { await getImageCoords(mapToAppendTo, file, settings, app); return; } + const fileCache = app.metadataCache.getFileCache(file); const frontMatter = fileCache?.frontmatter; if (frontMatter) { @@ -66,6 +69,19 @@ export async function buildAndAppendFileMarkers(mapToAppendTo: FileMarker[], fil } } } +async function getImageCoords(mapToAppendTo: FileMarker[], file: TFile, settings: PluginSettings, app: App) { + let jeff = await exifr.parse(await app.vault.adapter.readBinary(file.path)); + let { latitude, longitude } = jeff; + if (latitude && longitude) { + let leafletMarker = new FileMarker(file, new leaflet.LatLng(latitude, longitude)); + leafletMarker.icon = getImageMarker(settings); + mapToAppendTo.push(leafletMarker); + } +} +function getImageMarker(settings: PluginSettings) { + return getIconFromOptions(Object.assign({}, settings.markerIcons.default, { "prefix": "fas", "icon": "fa-camera" })); +} + export async function buildMarkers(files: TFile[], settings: PluginSettings, app: App): Promise { let markers: FileMarker[] = []; @@ -75,7 +91,8 @@ export async function buildMarkers(files: TFile[], settings: PluginSettings, app return markers; } -function getIconForMarker(marker: FileMarker, settings: PluginSettings, app: App) : leaflet.Icon { + +function getIconForMarker(marker: FileMarker, settings: PluginSettings, app: App): leaflet.Icon { let result = settings.markerIcons.default; const fileCache = app.metadataCache.getFileCache(marker.file); if (fileCache && fileCache.tags) { @@ -90,7 +107,9 @@ function getIconForMarker(marker: FileMarker, settings: PluginSettings, app: App return getIconFromOptions(result); } -export function getIconFromOptions(iconSpec: leaflet.BaseIconOptions) : leaflet.Icon { + + +export function getIconFromOptions(iconSpec: leaflet.BaseIconOptions): leaflet.Icon { // Ugly hack for obsidian-leaflet compatability, see https://github.com/esm7/obsidian-map-view/issues/6 // @ts-ignore const backupL = L; @@ -138,14 +157,15 @@ async function getMarkersFromFileContent(file: TFile, settings: PluginSettings, return markers; } -export function getFrontMatterLocation(file: TFile, app: App) : leaflet.LatLng { + +export function getFrontMatterLocation(file: TFile, app: App): leaflet.LatLng { const fileCache = app.metadataCache.getFileCache(file); const frontMatter = fileCache?.frontmatter; if (frontMatter && frontMatter?.location) { try { const location = frontMatter.location; // We have a single location at hand - if (location.length == 2 && typeof(location[0]) === 'number' && typeof(location[1]) === 'number') { + if (location.length == 2 && typeof (location[0]) === 'number' && typeof (location[1]) === 'number') { const location = new leaflet.LatLng(frontMatter.location[0], frontMatter.location[1]); verifyLocation(location); return location; diff --git a/src/settings.ts b/src/settings.ts index f8e7ba2..30e4470 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,6 +1,8 @@ import * as consts from 'src/consts'; + +import { SplitDirection, TFile } from 'obsidian'; + import { LatLng } from 'leaflet'; -import { SplitDirection } from 'obsidian'; export type PluginSettings = { darkMode: boolean; @@ -16,19 +18,25 @@ export type PluginSettings = { newNoteNameFormat?: string; newNotePath?: string; newNoteTemplate?: string; -} + imageMatcher: RegExp; +}; export const DEFAULT_SETTINGS: PluginSettings = { darkMode: false, markerIcons: { - "default": {"prefix": "fas", "icon": "fa-circle", "markerColor": "blue"}, - "#trip": {"prefix": "fas", "icon": "fa-hiking", "markerColor": "green"}, - "#trip-water": {"prefix": "fas", "markerColor": "blue"}, - "#dogs": {"prefix": "fas", "icon": "fa-paw"}, + "default": { "prefix": "fas", "icon": "fa-circle", "markerColor": "blue" }, + "#trip": { "prefix": "fas", "icon": "fa-hiking", "markerColor": "green" }, + "#trip-water": { "prefix": "fas", "markerColor": "blue" }, + "#dogs": { "prefix": "fas", "icon": "fa-paw" }, }, zoomOnGoFromNote: 15, tilesUrl: consts.TILES_URL_OPENSTREETMAP, autoZoom: true, markerClickBehavior: 'samePane', - newNoteNameFormat: 'Location added on {{date:YYYY-MM-DD}}T{{date:HH-mm}}' + newNoteNameFormat: 'Location added on {{date:YYYY-MM-DD}}T{{date:HH-mm}}', + imageMatcher: /(?:png|jpe?g)/i, }; + +export function isImage(file: TFile) { + return file.extension.match(DEFAULT_SETTINGS.imageMatcher); +} \ No newline at end of file From d90e22037a5be92dccf73f7083f22410d90515a8 Mon Sep 17 00:00:00 2001 From: geoffreysflaminglasersword Date: Mon, 26 Jul 2021 02:18:22 -0500 Subject: [PATCH 2/6] Added option to turn of image detection --- src/main.ts | 98 +++++++++++++++++++++++++++++-------------------- src/mapView.ts | 4 +- src/settings.ts | 2 + 3 files changed, 64 insertions(+), 40 deletions(-) diff --git a/src/main.ts b/src/main.ts index 09c9dd6..6f02aee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,7 +15,7 @@ export default class MapViewPlugin extends Plugin { await this.loadSettings(); this.addRibbonIcon('globe', 'Open map view', () => { - this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); + this.app.workspace.getLeaf().setViewState({ type: consts.MAP_VIEW_NAME }); }); this.registerView(consts.MAP_VIEW_NAME, (leaf: WorkspaceLeaf) => { @@ -26,7 +26,7 @@ export default class MapViewPlugin extends Plugin { id: 'open-map-view', name: 'Open Map View', callback: () => { - this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); + this.app.workspace.getLeaf().setViewState({ type: consts.MAP_VIEW_NAME }); }, }); @@ -80,7 +80,8 @@ export default class MapViewPlugin extends Plugin { state: { mapCenter: location, mapZoom: this.settings.zoomOnGoFromNote - } as any}); + } as any + }); } private getLocationOnEditorLine(editor: Editor, view: FileView): leaflet.LatLng { @@ -89,8 +90,7 @@ export default class MapViewPlugin extends Plugin { let selectedLocation = null; if (match) selectedLocation = new leaflet.LatLng(parseFloat(match[1]), parseFloat(match[2])); - else - { + else { const fmLocation = getFrontMatterLocation(view.file, this.app); if (line.indexOf('location') > -1 && fmLocation) selectedLocation = fmLocation; @@ -128,98 +128,118 @@ class SettingsTab extends PluginSettingTab { containerEl.empty(); - containerEl.createEl('h2', {text: 'Settings for the map view plugin.'}); + containerEl.createEl('h2', { text: 'Settings for the map view plugin.' }); + + new Setting(containerEl) + .setName('Automatically add markers for images') + .setDesc('Search the vault for image files and add markers on the map if they have location data') + .addToggle(component => { + component + .setValue(this.plugin.settings.detectImageLocations) + .onChange(async (value) => { + this.plugin.settings.detectImageLocations = value; + await this.plugin.saveSettings(); + }); + }); new Setting(containerEl) .setName('Map follows search results') .setDesc('Auto focus the map to fit search results.') - .addToggle(component => {component - .setValue(this.plugin.settings.autoZoom) - .onChange(async (value) => { - this.plugin.settings.autoZoom = value; - await this.plugin.saveSettings(); - }) + .addToggle(component => { + component + .setValue(this.plugin.settings.autoZoom) + .onChange(async (value) => { + this.plugin.settings.autoZoom = value; + await this.plugin.saveSettings(); + }); }); new Setting(containerEl) .setName('Default action for map marker click') .setDesc('How should the corresponding note be opened when clicking a map marker? Either way, CTRL reverses the behavior.') - .addDropdown(component => { component - .addOption('samePane', 'Open in same pane (replace map view)') + .addDropdown(component => { + component + .addOption('samePane', 'Open in same pane (replace map view)') .addOption('secondPane', 'Open in a 2nd pane and keep reusing it') .addOption('alwaysNew', 'Always open a new pane') .setValue(this.plugin.settings.markerClickBehavior || 'samePane') .onChange(async (value: any) => { this.plugin.settings.markerClickBehavior = value; this.plugin.saveSettings(); - }) + }); }); new Setting(containerEl) .setName('New pane split direction') .setDesc('Which way should the pane be split when opening in a new pane.') - .addDropdown(component => { component - .addOption('horizontal', 'Horizontal') + .addDropdown(component => { + component + .addOption('horizontal', 'Horizontal') .addOption('vertical', 'Vertical') .setValue(this.plugin.settings.newPaneSplitDirection || 'horizontal') - .onChange(async (value: any) => { - this.plugin.settings.newPaneSplitDirection = value; - this.plugin.saveSettings(); - }) + .onChange(async (value: any) => { + this.plugin.settings.newPaneSplitDirection = value; + this.plugin.saveSettings(); + }); }); new Setting(containerEl) .setName('New note name format') .setDesc('Date/times in the format can be wrapped in {{date:...}}, e.g. "note-{{date:YYYY-MM-DD}}".') - .addText(component => { component - .setValue(this.plugin.settings.newNoteNameFormat || DEFAULT_SETTINGS.newNoteNameFormat) + .addText(component => { + component + .setValue(this.plugin.settings.newNoteNameFormat || DEFAULT_SETTINGS.newNoteNameFormat) .onChange(async (value: string) => { this.plugin.settings.newNoteNameFormat = value; this.plugin.saveSettings(); - }) + }); }); new Setting(containerEl) .setName('New note location') .setDesc('Location for notes created from the map.') - .addText(component => { component - .setValue(this.plugin.settings.newNotePath || '') + .addText(component => { + component + .setValue(this.plugin.settings.newNotePath || '') .onChange(async (value: string) => { this.plugin.settings.newNotePath = value; this.plugin.saveSettings(); - }) + }); }); new Setting(containerEl) .setName('Template file location') .setDesc('Choose the file to use as a template, e.g. "templates/map-log.md".') - .addText(component => { component - .setValue(this.plugin.settings.newNoteTemplate || '') + .addText(component => { + component + .setValue(this.plugin.settings.newNoteTemplate || '') .onChange(async (value: string) => { this.plugin.settings.newNoteTemplate = value; this.plugin.saveSettings(); - }) + }); }); new Setting(containerEl) .setName('Default zoom for "show on map" action') .setDesc('When jumping to the map from a note, what should be the display zoom?') - .addSlider(component => {component - .setLimits(1, 18, 1) + .addSlider(component => { + component + .setLimits(1, 18, 1) .setValue(this.plugin.settings.zoomOnGoFromNote) - .onChange(async (value) => { - this.plugin.settings.zoomOnGoFromNote = value; - await this.plugin.saveSettings(); - }) + .onChange(async (value) => { + this.plugin.settings.zoomOnGoFromNote = value; + await this.plugin.saveSettings(); + }); }); new Setting(containerEl) .setName('Map source (advanced)') .setDesc('Source for the map tiles, see the documentation for more details. Requires to close & reopen the map.') - .addText(component => {component - .setValue(this.plugin.settings.tilesUrl) + .addText(component => { + component + .setValue(this.plugin.settings.tilesUrl) .onChange(async (value) => { this.plugin.settings.tilesUrl = value; await this.plugin.saveSettings(); - }) + }); }); new Setting(containerEl) diff --git a/src/mapView.ts b/src/mapView.ts index 6e64c88..94554f2 100644 --- a/src/mapView.ts +++ b/src/mapView.ts @@ -274,7 +274,8 @@ export class MapView extends ItemView { const allFiles = this.app.vault.getFiles(); for (const file of allFiles) { // only show images when not filtering by tag, currently not a clean way of adding tags to images - if (!tags?.length && isImage(file)) results.push(file); + console.log(`this.settings.detectImageLocations`, this.settings.detectImageLocations); + if (!tags?.length && this.settings.detectImageLocations && isImage(file)) results.push(file); else if (!tags?.length || this.app.metadataCache.getFileCache(file)?.tags?.some(t => tags.includes(t.tag))) results.push(file); } @@ -284,6 +285,7 @@ export class MapView extends ItemView { updateMapMarkers(newMarkers: FileMarker[]) { let newMarkersMap: MarkersMap = new Map(); for (let marker of newMarkers) { + if (isImage(marker.file) && !this.settings.detectImageLocations) continue; //don't update if the setting has been changed const existingMarker = this.display.markers.has(marker.id) ? this.display.markers.get(marker.id) : null; if (existingMarker && existingMarker.isSame(marker)) { diff --git a/src/settings.ts b/src/settings.ts index 30e4470..436566c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -18,6 +18,7 @@ export type PluginSettings = { newNoteNameFormat?: string; newNotePath?: string; newNoteTemplate?: string; + detectImageLocations: boolean; imageMatcher: RegExp; }; @@ -34,6 +35,7 @@ export const DEFAULT_SETTINGS: PluginSettings = { autoZoom: true, markerClickBehavior: 'samePane', newNoteNameFormat: 'Location added on {{date:YYYY-MM-DD}}T{{date:HH-mm}}', + detectImageLocations: true, imageMatcher: /(?:png|jpe?g)/i, }; From 31cf9715b63fa1932c1da50e2378a7648e0edd70 Mon Sep 17 00:00:00 2001 From: geoffreysflaminglasersword Date: Mon, 26 Jul 2021 02:43:30 -0500 Subject: [PATCH 3/6] undid formatting changes --- rollup.config.js | 6 +++--- src/main.ts | 50 ++++++++++++++++++++---------------------------- src/markers.ts | 14 +++++--------- src/settings.ts | 14 ++++++-------- 4 files changed, 35 insertions(+), 49 deletions(-) diff --git a/rollup.config.js b/rollup.config.js index 20d7902..6df211e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,9 +1,9 @@ -import commonjs from '@rollup/plugin-commonjs'; -import copy from 'rollup-plugin-copy'; +import typescript from '@rollup/plugin-typescript'; import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; import postcss from 'rollup-plugin-postcss'; import postcss_url from 'postcss-url'; -import typescript from '@rollup/plugin-typescript'; +import copy from 'rollup-plugin-copy'; const isProd = (process.env.BUILD === 'production'); diff --git a/src/main.ts b/src/main.ts index 6f02aee..f66d85d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,7 +15,7 @@ export default class MapViewPlugin extends Plugin { await this.loadSettings(); this.addRibbonIcon('globe', 'Open map view', () => { - this.app.workspace.getLeaf().setViewState({ type: consts.MAP_VIEW_NAME }); + this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); }); this.registerView(consts.MAP_VIEW_NAME, (leaf: WorkspaceLeaf) => { @@ -26,7 +26,7 @@ export default class MapViewPlugin extends Plugin { id: 'open-map-view', name: 'Open Map View', callback: () => { - this.app.workspace.getLeaf().setViewState({ type: consts.MAP_VIEW_NAME }); + this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); }, }); @@ -80,8 +80,7 @@ export default class MapViewPlugin extends Plugin { state: { mapCenter: location, mapZoom: this.settings.zoomOnGoFromNote - } as any - }); + } as any}); } private getLocationOnEditorLine(editor: Editor, view: FileView): leaflet.LatLng { @@ -90,7 +89,8 @@ export default class MapViewPlugin extends Plugin { let selectedLocation = null; if (match) selectedLocation = new leaflet.LatLng(parseFloat(match[1]), parseFloat(match[2])); - else { + else + { const fmLocation = getFrontMatterLocation(view.file, this.app); if (line.indexOf('location') > -1 && fmLocation) selectedLocation = fmLocation; @@ -145,20 +145,18 @@ class SettingsTab extends PluginSettingTab { new Setting(containerEl) .setName('Map follows search results') .setDesc('Auto focus the map to fit search results.') - .addToggle(component => { - component + .addToggle(component => {component .setValue(this.plugin.settings.autoZoom) .onChange(async (value) => { this.plugin.settings.autoZoom = value; await this.plugin.saveSettings(); - }); + }) }); new Setting(containerEl) .setName('Default action for map marker click') .setDesc('How should the corresponding note be opened when clicking a map marker? Either way, CTRL reverses the behavior.') - .addDropdown(component => { - component + .addDropdown(component => { component .addOption('samePane', 'Open in same pane (replace map view)') .addOption('secondPane', 'Open in a 2nd pane and keep reusing it') .addOption('alwaysNew', 'Always open a new pane') @@ -166,80 +164,74 @@ class SettingsTab extends PluginSettingTab { .onChange(async (value: any) => { this.plugin.settings.markerClickBehavior = value; this.plugin.saveSettings(); - }); + }) }); new Setting(containerEl) .setName('New pane split direction') .setDesc('Which way should the pane be split when opening in a new pane.') - .addDropdown(component => { - component + .addDropdown(component => { component .addOption('horizontal', 'Horizontal') .addOption('vertical', 'Vertical') .setValue(this.plugin.settings.newPaneSplitDirection || 'horizontal') .onChange(async (value: any) => { this.plugin.settings.newPaneSplitDirection = value; this.plugin.saveSettings(); - }); + }) }); new Setting(containerEl) .setName('New note name format') .setDesc('Date/times in the format can be wrapped in {{date:...}}, e.g. "note-{{date:YYYY-MM-DD}}".') - .addText(component => { - component + .addText(component => { component .setValue(this.plugin.settings.newNoteNameFormat || DEFAULT_SETTINGS.newNoteNameFormat) .onChange(async (value: string) => { this.plugin.settings.newNoteNameFormat = value; this.plugin.saveSettings(); - }); + }) }); new Setting(containerEl) .setName('New note location') .setDesc('Location for notes created from the map.') - .addText(component => { - component + .addText(component => { component .setValue(this.plugin.settings.newNotePath || '') .onChange(async (value: string) => { this.plugin.settings.newNotePath = value; this.plugin.saveSettings(); - }); + }) }); new Setting(containerEl) .setName('Template file location') .setDesc('Choose the file to use as a template, e.g. "templates/map-log.md".') - .addText(component => { - component + .addText(component => { component .setValue(this.plugin.settings.newNoteTemplate || '') .onChange(async (value: string) => { this.plugin.settings.newNoteTemplate = value; this.plugin.saveSettings(); - }); + }) }); new Setting(containerEl) .setName('Default zoom for "show on map" action') .setDesc('When jumping to the map from a note, what should be the display zoom?') - .addSlider(component => { - component + .addSlider(component => {component .setLimits(1, 18, 1) .setValue(this.plugin.settings.zoomOnGoFromNote) .onChange(async (value) => { this.plugin.settings.zoomOnGoFromNote = value; await this.plugin.saveSettings(); - }); + }) }); new Setting(containerEl) .setName('Map source (advanced)') .setDesc('Source for the map tiles, see the documentation for more details. Requires to close & reopen the map.') - .addText(component => { - component + .addText(component => {component .setValue(this.plugin.settings.tilesUrl) .onChange(async (value) => { this.plugin.settings.tilesUrl = value; await this.plugin.saveSettings(); - }); + }) }); new Setting(containerEl) diff --git a/src/markers.ts b/src/markers.ts index 93aebe5..4bff691 100644 --- a/src/markers.ts +++ b/src/markers.ts @@ -41,7 +41,7 @@ export class FileMarker { this.icon?.options?.shape === other.icon?.options?.shape; } - generateId(): MarkerId { + generateId() : MarkerId { return this.file.name + this.location.lat.toString() + this.location.lng.toString(); } } @@ -91,8 +91,7 @@ export async function buildMarkers(files: TFile[], settings: PluginSettings, app return markers; } - -function getIconForMarker(marker: FileMarker, settings: PluginSettings, app: App): leaflet.Icon { +function getIconForMarker(marker: FileMarker, settings: PluginSettings, app: App) : leaflet.Icon { let result = settings.markerIcons.default; const fileCache = app.metadataCache.getFileCache(marker.file); if (fileCache && fileCache.tags) { @@ -107,9 +106,7 @@ function getIconForMarker(marker: FileMarker, settings: PluginSettings, app: App return getIconFromOptions(result); } - - -export function getIconFromOptions(iconSpec: leaflet.BaseIconOptions): leaflet.Icon { +export function getIconFromOptions(iconSpec: leaflet.BaseIconOptions) : leaflet.Icon { // Ugly hack for obsidian-leaflet compatability, see https://github.com/esm7/obsidian-map-view/issues/6 // @ts-ignore const backupL = L; @@ -157,15 +154,14 @@ async function getMarkersFromFileContent(file: TFile, settings: PluginSettings, return markers; } - -export function getFrontMatterLocation(file: TFile, app: App): leaflet.LatLng { +export function getFrontMatterLocation(file: TFile, app: App) : leaflet.LatLng { const fileCache = app.metadataCache.getFileCache(file); const frontMatter = fileCache?.frontmatter; if (frontMatter && frontMatter?.location) { try { const location = frontMatter.location; // We have a single location at hand - if (location.length == 2 && typeof (location[0]) === 'number' && typeof (location[1]) === 'number') { + if (location.length == 2 && typeof (location[0]) === 'number' && typeof(location[1]) === 'number') { const location = new leaflet.LatLng(frontMatter.location[0], frontMatter.location[1]); verifyLocation(location); return location; diff --git a/src/settings.ts b/src/settings.ts index 436566c..58d34c6 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,8 +1,6 @@ import * as consts from 'src/consts'; - -import { SplitDirection, TFile } from 'obsidian'; - import { LatLng } from 'leaflet'; +import { SplitDirection, TFile } from 'obsidian'; export type PluginSettings = { darkMode: boolean; @@ -20,15 +18,15 @@ export type PluginSettings = { newNoteTemplate?: string; detectImageLocations: boolean; imageMatcher: RegExp; -}; +} export const DEFAULT_SETTINGS: PluginSettings = { darkMode: false, markerIcons: { - "default": { "prefix": "fas", "icon": "fa-circle", "markerColor": "blue" }, - "#trip": { "prefix": "fas", "icon": "fa-hiking", "markerColor": "green" }, - "#trip-water": { "prefix": "fas", "markerColor": "blue" }, - "#dogs": { "prefix": "fas", "icon": "fa-paw" }, + "default": {"prefix": "fas", "icon": "fa-circle", "markerColor": "blue"}, + "#trip": {"prefix": "fas", "icon": "fa-hiking", "markerColor": "green"}, + "#trip-water": {"prefix": "fas", "markerColor": "blue"}, + "#dogs": {"prefix": "fas", "icon": "fa-paw"}, }, zoomOnGoFromNote: 15, tilesUrl: consts.TILES_URL_OPENSTREETMAP, From baf3067b1c42c8f5591c183fa2bf220ff1ecd6f7 Mon Sep 17 00:00:00 2001 From: geoffreysflaminglasersword Date: Thu, 29 Jul 2021 18:10:48 -0500 Subject: [PATCH 4/6] Added filemarker cachingk --- .vscode/settings.json | 3 + rollup.config.js | 2 +- src/main.ts | 87 ++++++++++++-- src/mapView.ts | 256 ++++++++++++++++++++++++++---------------- src/markers.ts | 50 +++------ src/settings.ts | 18 +-- 6 files changed, 265 insertions(+), 151 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5197260 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "sort-imports.on-save": false +} \ No newline at end of file diff --git a/rollup.config.js b/rollup.config.js index 6df211e..0ac6902 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -17,7 +17,7 @@ if you want to view the source visit the plugins github repository export default { input: 'src/main.ts', output: { - dir: './dist', + dir: './', sourcemap: isProd ? false : 'inline', sourcemapExcludeSources: isProd, format: 'cjs', diff --git a/src/main.ts b/src/main.ts index f66d85d..7b1ecba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,12 +3,30 @@ import * as consts from 'src/consts'; import * as leaflet from 'leaflet'; import { MapView } from 'src/mapView'; -import { PluginSettings, DEFAULT_SETTINGS } from 'src/settings'; -import { getFrontMatterLocation, matchInlineLocation, verifyLocation } from 'src/markers'; +import { PluginSettings, DEFAULT_SETTINGS, isImage } from 'src/settings'; +import { FileMarker, markersFromMd, getFrontMatterLocation, getIconFromOptions, matchInlineLocation, verifyLocation } from 'src/markers'; +import exifr from "exifr"; + +type Extractor = (f: TFile) => Promise; +type Predicate = (f: TFile) => boolean; +type Rule = {pred:Predicate, extract:Extractor}; + export default class MapViewPlugin extends Plugin { settings: PluginSettings; + CACHE_RULES: Rule[] = [ + { pred: (f: TFile) => this.settings.detectImageLocations && isImage(f), extract: (f: TFile) => this.getImageCoords(f) }, + { pred: (f: TFile) => f.extension == 'md', extract: (f: TFile) => markersFromMd(f, this.settings, this.app) }, + ]; + private _fileCache: Map; + private *cacheGen () { for (let val of this._fileCache.values()) yield Promise.resolve(val[2]); } + // BUG: sometimes this gets called twice before the cache has been populated, which parses all the files multiple times (only affects performance) + public get fileCache() { + if (!this._fileCache) return this.cacheAdd(); + else return this.cacheGen(); + } + async onload() { addIcon('globe', consts.RIBBON_ICON); @@ -39,7 +57,7 @@ export default class MapViewPlugin extends Plugin { menu.addItem((item: MenuItem) => { item.setTitle('Show on map'); item.setIcon('globe'); - item.onClick(async () => await this.openMapWithLocation(location)); + item.onClick(() => this.openMapWithLocation(location)); }); menu.addItem((item: MenuItem) => { item.setTitle('Open in Google Maps'); @@ -60,7 +78,7 @@ export default class MapViewPlugin extends Plugin { menu.addItem((item: MenuItem) => { item.setTitle('Show on map'); item.setIcon('globe'); - item.onClick(async () => await this.openMapWithLocation(location)); + item.onClick(() => this.openMapWithLocation(location)); }); menu.addItem((item: MenuItem) => { item.setTitle('Open in Google Maps'); @@ -71,7 +89,7 @@ export default class MapViewPlugin extends Plugin { } } }); - + this.app.vault.on('delete',file => this.cacheRemove(file)); } private async openMapWithLocation(location: leaflet.LatLng) { @@ -80,7 +98,8 @@ export default class MapViewPlugin extends Plugin { state: { mapCenter: location, mapZoom: this.settings.zoomOnGoFromNote - } as any}); + } as any + }); } private getLocationOnEditorLine(editor: Editor, view: FileView): leaflet.LatLng { @@ -89,8 +108,7 @@ export default class MapViewPlugin extends Plugin { let selectedLocation = null; if (match) selectedLocation = new leaflet.LatLng(parseFloat(match[1]), parseFloat(match[2])); - else - { + else { const fmLocation = getFrontMatterLocation(view.file, this.app); if (line.indexOf('location') > -1 && fmLocation) selectedLocation = fmLocation; @@ -106,14 +124,64 @@ export default class MapViewPlugin extends Plugin { } async loadSettings() { + //TODO: loading and saving the cache from/to a json file would make the map ready faster for large vaults this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); } -} + async getImageCoords(file: TFile) { + try{ + let { latitude, longitude } = await exifr.parse(await this.app.vault.adapter.readBinary(file.path)); + if(!latitude || !longitude) throw 0; + let leafletMarker = new FileMarker(file, new leaflet.LatLng(latitude, longitude)); + leafletMarker.icon = this.getImageMarker(this.settings); + return leafletMarker; + } catch{/* just ignore file parsing errors or empty location data for now*/} + return null; + } + + //TODO: as more filetypes are supported, icons should be configurable for each + getImageMarker(settings: PluginSettings) { + return getIconFromOptions(Object.assign({}, settings.markerIcons.default, { "prefix": "fas", "icon": "fa-camera" })); + } + + // TODO: should probably make the cache it's own class + public cacheGet(file: TFile) { return this._fileCache.get(file.path)[2]; } + public cacheRemove(...files: TAbstractFile[]) { if (files?.length) for (let file of files) this._fileCache.delete(file.path); } + async cacheReset() { + this._fileCache = null; + for (let f of this.cacheAdd()); + } + + *cacheAdd(...files: TFile[]) { + let add = async (file: TFile) => { + let existing = this._fileCache.get(file.path); + if (existing) return existing[2]; + for (const rule of this.CACHE_RULES) + if (rule.pred(file)) { + let marker = await rule.extract(file); + if (marker) { + this._fileCache.set(file.path, [file, rule, marker]); + return marker; + } + } + return null; + }; + if (!this._fileCache) this._fileCache = new Map(); + if (files?.length) for (let file of files) yield add(file); + else { + console.log('Loading cache...'); + // md files will be faster to extract from so we do them first + let markdownFilesFirst = this.app.vault.getFiles().sort((a, b) => + a.extension == b.extension ? 0 : a.extension == 'md' ? 1 : -1 + ); + for (let file of markdownFilesFirst) yield add(file); + } + } +} class SettingsTab extends PluginSettingTab { plugin: MapViewPlugin; @@ -138,6 +206,7 @@ class SettingsTab extends PluginSettingTab { .setValue(this.plugin.settings.detectImageLocations) .onChange(async (value) => { this.plugin.settings.detectImageLocations = value; + this.plugin.cacheReset(); await this.plugin.saveSettings(); }); }); diff --git a/src/mapView.ts b/src/mapView.ts index 94554f2..0c23e48 100644 --- a/src/mapView.ts +++ b/src/mapView.ts @@ -1,4 +1,4 @@ -import { App, TAbstractFile, Editor, ButtonComponent, MarkdownView, getAllTags, ItemView, MenuItem, Menu, TFile, TextComponent, DropdownComponent, WorkspaceLeaf } from 'obsidian'; +import { TAbstractFile, Editor, ButtonComponent, MarkdownView, getAllTags, ItemView, MenuItem, Menu, TFile, TextComponent, DropdownComponent, WorkspaceLeaf } from 'obsidian'; import * as leaflet from 'leaflet'; // Ugly hack for obsidian-leaflet compatability, see https://github.com/esm7/obsidian-map-view/issues/6 // @ts-ignore @@ -9,8 +9,8 @@ import { GeoSearchControl, OpenStreetMapProvider } from 'leaflet-geosearch'; import 'leaflet-geosearch/dist/geosearch.css'; import * as consts from 'src/consts'; -import { PluginSettings, DEFAULT_SETTINGS, isImage } from 'src/settings'; -import { MarkersMap, FileMarker, buildMarkers, getIconFromOptions, buildAndAppendFileMarkers } from 'src/markers'; +import { PluginSettings, DEFAULT_SETTINGS } from 'src/settings'; +import { MarkersMap, FileMarker, getIconFromOptions } from 'src/markers'; import MapViewPlugin from 'src/main'; import * as utils from 'src/utils'; @@ -27,7 +27,7 @@ export class MapView extends ItemView { private state: MapState; private display = new class { map: leaflet.Map; - markers: MarkersMap = new Map(); + markers: MarkersMap; mapDiv: HTMLDivElement; tagsBox: TextComponent; }; @@ -35,6 +35,9 @@ export class MapView extends ItemView { private defaultState: MapState; private newPaneLeaf: WorkspaceLeaf; private isOpen: boolean = false; + private createTimer:any; + private refreshTimer:any; + private boxTimer:any; public onAfterOpen: (map: leaflet.Map, markers: MarkersMap) => any = null; @@ -50,21 +53,42 @@ export class MapView extends ItemView { tags: this.settings.defaultTags || consts.DEFAULT_TAGS, version: 0 }; - this.setState = async (state: MapState, result) => { + + this.getState = () => this.state; + this.setState = async (state: MapState, result: any = null) => { if (state) { console.log(`Received setState:`, state); // We give the given state priority by setting a high version state.version = 100; - await this.updateMapToState(state); + this.updateMapToState(state); } }; - this.getState = (): MapState => { - return this.state; - }; - this.app.vault.on('delete', file => this.updateMarkersWithRelationToFile(file.path, null, true)); - this.app.vault.on('rename', (file, oldPath) => this.updateMarkersWithRelationToFile(oldPath, file, true)); - this.app.metadataCache.on('changed', file => this.updateMarkersWithRelationToFile(file.path, file, false)); + this.app.metadataCache.on('resolved', () => { + this.app.workspace.onLayoutReady(() => { + this.display.map.whenReady(() => { + this.updateMapToState(this.defaultState).then(() => { + this.refreshView(); + if (this.onAfterOpen != null) + this.onAfterOpen(this.display.map, this.display.markers); + }); + }); + + this.app.vault.on('rename', (file, oldPath) => this.handleRenameMarker(file, oldPath)); + this.app.metadataCache.on('changed', file => this.handleUpdateMarker(file)); + this.app.vault.on('delete', file => this.handleRemoveMarker(file)); + + // unfortunately create recieves an abstract file so whenever we detect created files we have to refresh the cache, + // in case of rapid creation we do this on a timeout in order to refresh only once + this.app.vault.on('create', () => { + if (this.createTimer) { window.clearTimeout(this.createTimer); this.createTimer = null; } + this.createTimer = window.setTimeout(() => { + this.createTimer = null; + this.plugin.cacheReset().then(() => this.updateMapToState(this.state,true)); + }, 2000); + }); + }); + }); } getViewType() { return 'map'; } @@ -84,8 +108,12 @@ export class MapView extends ItemView { this.display.tagsBox = new TextComponent(controlsDiv); this.display.tagsBox.setPlaceholder('Tags, e.g. "#one,#two"'); this.display.tagsBox.onChange(async (tagsBox: string) => { - that.state.tags = tagsBox.split(',').filter(t => t.length > 0); - await this.updateMapToState(this.state, this.settings.autoZoom); + if (this.boxTimer) { window.clearTimeout(this.boxTimer); this.boxTimer = null; } + this.boxTimer = window.setTimeout(() => { + this.boxTimer = null; + that.state.tags = tagsBox?.length ? tagsBox.split(',').filter(t => t.length > 0) : null; + this.updateMapToState(this.state); + }, 600); //enough time to not trigger while typing without feeling slow to respond }); let tagSuggestions = new DropdownComponent(controlsDiv); tagSuggestions.setValue('Quick add tag'); @@ -105,14 +133,14 @@ export class MapView extends ItemView { goDefault .setButtonText('Reset') .setTooltip('Reset the view to the defined default.') - .onClick(async () => { + .onClick(() => { let newState = { mapZoom: this.settings.defaultZoom || consts.DEFAULT_ZOOM, mapCenter: this.settings.defaultMapCenter || consts.DEFAULT_CENTER, tags: this.settings.defaultTags || consts.DEFAULT_TAGS, version: this.state.version + 1 }; - await this.updateMapToState(newState); + this.updateMapToState(newState); }); let fitButton = new ButtonComponent(controlsDiv); fitButton @@ -153,7 +181,6 @@ export class MapView extends ItemView { } async createMap() { - var that = this; // LeafletJS compatability: disable tree-shaking for the full-screen module var dummy = leafletFullscreen; this.display.map = new leaflet.Map(this.display.mapDiv, { @@ -241,94 +268,141 @@ export class MapView extends ItemView { }); mapPopup.showAtPosition(event.originalEvent); }); - this.display.map.whenReady(async () => { - await that.updateMapToState(this.defaultState, !this.settings.defaultZoom); - if (this.onAfterOpen != null) - this.onAfterOpen(this.display.map, this.display.markers); - }); - } // Updates the map to the given state and then sets the state accordingly, but only if the given state version // is not lower than the current state version (so concurrent async updates always keep the latest one) - async updateMapToState(state: MapState, autoFit: boolean = false) { - const files = await this.getFileListByQuery(state.tags); - let newMarkers = await buildMarkers(files, this.settings, this.app); - if (state.version < this.state.version) { - // If the state we were asked to update is old (e.g. because while we were building markers a newer instance - // of the method was called), cancel the update - return; - } - this.state = state; - this.updateMapMarkers(newMarkers); + async updateMapToState(state: MapState, updateFromCache = false) { + // If the state we we're asked to update is old (e.g. because while we were building markers a newer instance + // of the method was called), cancel the update + if (state.version < this.state.version) return; + else this.state = state; this.state.tags = this.state.tags || []; this.display.tagsBox.setValue(this.state.tags.filter(tag => tag.length > 0).join(',')); - if (this.state.mapCenter && this.state.mapZoom) - this.display.map.setView(this.state.mapCenter, this.state.mapZoom); - if (autoFit) - this.autoFitMapToMarkers(); + + this.fetchAndUpdateMapMarkers(updateFromCache); + this.cleanMarkers(); + for(let marker of this.markerQuery(state.tags)) this.showOrHideMarker(marker); } - async getFileListByQuery(tags: string[]) { - let results: TFile[] = []; - const allFiles = this.app.vault.getFiles(); - for (const file of allFiles) { - // only show images when not filtering by tag, currently not a clean way of adding tags to images - console.log(`this.settings.detectImageLocations`, this.settings.detectImageLocations); - if (!tags?.length && this.settings.detectImageLocations && isImage(file)) results.push(file); - else if (!tags?.length || this.app.metadataCache.getFileCache(file)?.tags?.some(t => tags.includes(t.tag))) - results.push(file); - } - return results; + private fetchAndUpdateMapMarkers(updateFromCache = false) { + if (!this.display.markers || updateFromCache) { + this.display.markers ??= new Map(); + for (const promise of this.plugin.fileCache) { + promise.then(async marker => { + if (!marker) return; + this.updateMarker(marker); + }); + } + } else for (const marker of this.display.markers.values()) + this.updateMarker(marker); } - updateMapMarkers(newMarkers: FileMarker[]) { - let newMarkersMap: MarkersMap = new Map(); - for (let marker of newMarkers) { - if (isImage(marker.file) && !this.settings.detectImageLocations) continue; //don't update if the setting has been changed - const existingMarker = this.display.markers.has(marker.id) ? - this.display.markers.get(marker.id) : null; - if (existingMarker && existingMarker.isSame(marker)) { - // This marker exists, so just keep it - newMarkersMap.set(marker.id, this.display.markers.get(marker.id)); + private async handleRemoveMarker(removed: TAbstractFile) { + if (!this.display.map || !this.isOpen) return; + this.display.markers.forEach(marker =>{ + if(marker.file.path == removed.path) { this.display.markers.delete(marker.id); - } else { - // New marker - create it - marker.mapMarker = leaflet.marker(marker.location, { icon: marker.icon || new leaflet.Icon.Default() }) - .addTo(this.display.map) - .bindTooltip(marker.file.name); - marker.mapMarker.on('click', (event: leaflet.LeafletMouseEvent) => { - this.goToMarker(marker, event.originalEvent.ctrlKey, true); + marker.mapMarker.removeFrom(this.display.map); + } + }); + } + private handleRenameMarker(renamed: TAbstractFile, oldPath: string) { + if (!this.display.map || !this.isOpen) return; + this.display.markers.forEach(marker => {if(marker.file.path == oldPath) Object.assign(marker.file,renamed)}); + } + /* + this currently recalculates all markers for a file that is changed while the map is open, + which could potentially be a bottleneck on a huge file with tons of markers, + but it's doubtful this would ever be an issue and finding exactly which marker changed adds unnecessary complexity + */ + private async handleUpdateMarker(changed: TFile) { + if (!this.display.map || !this.isOpen) return; + this.handleRemoveMarker(changed); + this.plugin.cacheRemove(changed); + for(let promise of this.plugin.cacheAdd(changed)) + promise.then(marker => this.updateMarker(marker)); + } + + updateMarker(marker: FileMarker|FileMarker[]) { + if(!marker) return; + + if (marker instanceof Array) { + marker.forEach(m => this.updateMarker(m)); + return; + } + + let existingMarker = this.display.markers.get(marker.id); + if (!existingMarker?.isSame(marker)) { + // New marker - create it + marker.mapMarker = leaflet.marker(marker.location, { icon: marker.icon || new leaflet.Icon.Default() }) + .addTo(this.display.map) + .bindTooltip(marker.file.name); + marker.mapMarker.on('click', (event: leaflet.LeafletMouseEvent) => { + this.goToMarker(marker, event.originalEvent.ctrlKey, true); + }); + marker.mapMarker.getElement().addEventListener('contextmenu', (ev: MouseEvent) => { + let mapPopup = new Menu(this.app); + mapPopup.setNoIcon(); + mapPopup.addItem((item: MenuItem) => { + item.setTitle('Open note'); + item.onClick(async ev => { this.goToMarker(marker, ev.ctrlKey, true); }); }); - marker.mapMarker.getElement().addEventListener('contextmenu', (ev: MouseEvent) => { - let mapPopup = new Menu(this.app); - mapPopup.setNoIcon(); - mapPopup.addItem((item: MenuItem) => { - item.setTitle('Open note'); - item.onClick(async ev => { this.goToMarker(marker, ev.ctrlKey, true); }); - }); - mapPopup.addItem((item: MenuItem) => { - item.setTitle('Open in Google Maps'); - item.onClick(ev => { - open(`https://maps.google.com/?q=${marker.location.lat},${marker.location.lng}`); - }); + mapPopup.addItem((item: MenuItem) => { + item.setTitle('Open in Google Maps'); + item.onClick(ev => { + open(`https://maps.google.com/?q=${marker.location.lat},${marker.location.lng}`); }); - mapPopup.showAtPosition(ev); - ev.stopPropagation(); }); - newMarkersMap.set(marker.id, marker); - } + mapPopup.showAtPosition(ev); + ev.stopPropagation(); + }); + this.display.markers.set(marker.id, marker); + this.refreshView(); + } + } + + /** has "side effect" of marking unmatched markers dirty so they are removed */ + *markerQuery(tags: string[]) { + //NOTE: this way of doing things means any tag filtering automatically excludes non-markdown files + //TODO: make it possible to insert multiple inclusion strategies, e.g. filter by file type + for (const [markerId,marker] of this.display.markers) { + if (tags?.length && !this.app.metadataCache.getFileCache(marker.file)?.tags?.some(t => tags.includes(t.tag))) + marker.dirty = true; + yield marker; } - for (let [key, value] of this.display.markers) { - value.mapMarker.removeFrom(this.display.map); + } + cleanMarkers(){ + this.display.markers.forEach(marker => marker.dirty = false); + } + showOrHideMarker(marker: FileMarker) { + if (marker?.dirty) marker.mapMarker.removeFrom(this.display.map); + else marker.mapMarker.addTo(this.display.map); + this.refreshView(); + } + + // each refresh request resets the timer unless it's been more than a second since the first request + // this causes periodic refreshes every second when getting many sequential requests + refreshStartTime:number; + refreshView() { + this.refreshStartTime ??= Date.now(); + if (this.refreshTimer){ + if(Date.now() - this.refreshStartTime < 1000) window.clearTimeout(this.refreshTimer); + else return; } - this.display.markers = newMarkersMap; + this.refreshTimer = window.setTimeout(async () => { + this.refreshTimer = null; + this.refreshStartTime = null; + if (this.state.mapCenter && this.state.mapZoom) + this.display.map.setView(this.state.mapCenter, this.state.mapZoom); + if(this.settings.autoZoom) this.autoFitMapToMarkers(); + }, 100); + } async autoFitMapToMarkers() { if (this.display.markers.size > 0) { const locations: leaflet.LatLng[] = Array.from(this.display.markers.values()).map(fileMarker => fileMarker.location); - console.log(`Auto fit by state:`, this.state); this.display.map.fitBounds(leaflet.latLngBounds(locations)); } } @@ -406,18 +480,6 @@ export class MapView extends ItemView { return null; } - private async updateMarkersWithRelationToFile(fileRemoved: string, fileAddedOrChanged: TAbstractFile, skipMetadata: boolean) { - if (!this.display.map || !this.isOpen) - return; - let newMarkers: FileMarker[] = []; - for (let [markerId, fileMarker] of this.display.markers) { - if (fileMarker.file.path !== fileRemoved) - newMarkers.push(fileMarker); - } - if (fileAddedOrChanged && fileAddedOrChanged instanceof TFile) - await buildAndAppendFileMarkers(newMarkers, fileAddedOrChanged, this.settings, this.app); - this.updateMapMarkers(newMarkers); - } - } + diff --git a/src/markers.ts b/src/markers.ts index 4bff691..a867a4f 100644 --- a/src/markers.ts +++ b/src/markers.ts @@ -1,14 +1,15 @@ -import { App, TFile } from 'obsidian'; -import * as leaflet from 'leaflet'; import 'leaflet-extra-markers'; import 'leaflet-extra-markers/dist/css/leaflet.extra-markers.min.css'; + +import * as consts from 'src/consts'; +import * as leaflet from 'leaflet'; + +import { App, TFile } from 'obsidian'; +import { PluginSettings } from 'src/settings'; // Ugly hack for obsidian-leaflet compatability, see https://github.com/esm7/obsidian-map-view/issues/6 // @ts-ignore let localL = L; -import { isImage, PluginSettings } from 'src/settings'; -import * as consts from 'src/consts'; -import exifr from "exifr"; type MarkerId = string; @@ -19,6 +20,7 @@ export class FileMarker { icon?: leaflet.Icon; mapMarker?: leaflet.Marker; id: MarkerId; + dirty:boolean = false; constructor(file: TFile, location: leaflet.LatLng) { this.file = file; @@ -41,6 +43,7 @@ export class FileMarker { this.icon?.options?.shape === other.icon?.options?.shape; } + //NOTE: given two of the same locations in the same file this results in only one marker eventually being created (which I assume is always ok) generateId() : MarkerId { return this.file.name + this.location.lat.toString() + this.location.lng.toString(); } @@ -48,47 +51,24 @@ export class FileMarker { export type MarkersMap = Map; -export async function buildAndAppendFileMarkers(mapToAppendTo: FileMarker[], file: TFile, settings: PluginSettings, app: App, skipMetadata?: boolean) { - if (isImage(file)) { await getImageCoords(mapToAppendTo, file, settings, app); return; } - +export async function markersFromMd(file: TFile, settings: PluginSettings, app: App) { const fileCache = app.metadataCache.getFileCache(file); const frontMatter = fileCache?.frontmatter; if (frontMatter) { - if (!skipMetadata) { + if ('locations' in frontMatter) { + const markersFromFile = await getMarkersFromFileContent(file, settings, app); + return markersFromFile; + } + else { const location = getFrontMatterLocation(file, app); if (location) { verifyLocation(location); let leafletMarker = new FileMarker(file, location); leafletMarker.icon = getIconForMarker(leafletMarker, settings, app); - mapToAppendTo.push(leafletMarker); + return leafletMarker; } } - if ('locations' in frontMatter) { - const markersFromFile = await getMarkersFromFileContent(file, settings, app); - mapToAppendTo.push(...markersFromFile); - } - } -} -async function getImageCoords(mapToAppendTo: FileMarker[], file: TFile, settings: PluginSettings, app: App) { - let jeff = await exifr.parse(await app.vault.adapter.readBinary(file.path)); - let { latitude, longitude } = jeff; - if (latitude && longitude) { - let leafletMarker = new FileMarker(file, new leaflet.LatLng(latitude, longitude)); - leafletMarker.icon = getImageMarker(settings); - mapToAppendTo.push(leafletMarker); - } -} -function getImageMarker(settings: PluginSettings) { - return getIconFromOptions(Object.assign({}, settings.markerIcons.default, { "prefix": "fas", "icon": "fa-camera" })); -} - - -export async function buildMarkers(files: TFile[], settings: PluginSettings, app: App): Promise { - let markers: FileMarker[] = []; - for (const file of files) { - await buildAndAppendFileMarkers(markers, file, settings, app); } - return markers; } function getIconForMarker(marker: FileMarker, settings: PluginSettings, app: App) : leaflet.Icon { diff --git a/src/settings.ts b/src/settings.ts index 58d34c6..805cc1f 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,6 +1,6 @@ import * as consts from 'src/consts'; import { LatLng } from 'leaflet'; -import { SplitDirection, TFile } from 'obsidian'; +import { SplitDirection, TAbstractFile, TFile } from 'obsidian'; export type PluginSettings = { darkMode: boolean; @@ -18,15 +18,15 @@ export type PluginSettings = { newNoteTemplate?: string; detectImageLocations: boolean; imageMatcher: RegExp; -} +}; export const DEFAULT_SETTINGS: PluginSettings = { darkMode: false, markerIcons: { - "default": {"prefix": "fas", "icon": "fa-circle", "markerColor": "blue"}, - "#trip": {"prefix": "fas", "icon": "fa-hiking", "markerColor": "green"}, - "#trip-water": {"prefix": "fas", "markerColor": "blue"}, - "#dogs": {"prefix": "fas", "icon": "fa-paw"}, + "default": { "prefix": "fas", "icon": "fa-circle", "markerColor": "blue" }, + "#trip": { "prefix": "fas", "icon": "fa-hiking", "markerColor": "green" }, + "#trip-water": { "prefix": "fas", "markerColor": "blue" }, + "#dogs": { "prefix": "fas", "icon": "fa-paw" }, }, zoomOnGoFromNote: 15, tilesUrl: consts.TILES_URL_OPENSTREETMAP, @@ -34,9 +34,9 @@ export const DEFAULT_SETTINGS: PluginSettings = { markerClickBehavior: 'samePane', newNoteNameFormat: 'Location added on {{date:YYYY-MM-DD}}T{{date:HH-mm}}', detectImageLocations: true, - imageMatcher: /(?:png|jpe?g)/i, + imageMatcher: /(?:png|jpe?g|tiff)/i, }; -export function isImage(file: TFile) { - return file.extension.match(DEFAULT_SETTINGS.imageMatcher); +export function isImage(file: TAbstractFile) { + return !!file.path.match(DEFAULT_SETTINGS.imageMatcher); } \ No newline at end of file From acbb730154b4e4e6ddc00f9c0a2b7cef4b60325d Mon Sep 17 00:00:00 2001 From: geoffreysflaminglasersword Date: Thu, 29 Jul 2021 18:27:11 -0500 Subject: [PATCH 5/6] reverted rollup directory to dist --- rollup.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rollup.config.js b/rollup.config.js index 0ac6902..6df211e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -17,7 +17,7 @@ if you want to view the source visit the plugins github repository export default { input: 'src/main.ts', output: { - dir: './', + dir: './dist', sourcemap: isProd ? false : 'inline', sourcemapExcludeSources: isProd, format: 'cjs', From 068bb3892a8d64f57602b9f2289c4d2bf7f5bbaa Mon Sep 17 00:00:00 2001 From: geoffreysflaminglasersword Date: Thu, 29 Jul 2021 18:29:00 -0500 Subject: [PATCH 6/6] removed vscode files --- .gitignore | 35 +-- .vscode/settings.json | 3 - README.md | 430 ++++++++++++++-------------- manifest.json | 16 +- rollup.config.js | 76 ++--- src/main.ts | 642 +++++++++++++++++++++--------------------- tsconfig.json | 42 +-- 7 files changed, 621 insertions(+), 623 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 0097734..65a96dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,18 @@ -# Intellij -*.iml -.idea - -# npm -node_modules -package-lock.json -dist - -# build -main.js -*.js.map - -# obsidian -data.json - -.history/** \ No newline at end of file +# Intellij +*.iml +.idea + +# npm +node_modules +package-lock.json +dist + +# build +main.js +*.js.map + +# obsidian +data.json + +.history/** +.vscode/** \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 5197260..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "sort-imports.on-save": false -} \ No newline at end of file diff --git a/README.md b/README.md index 144c17d..3e2b99c 100644 --- a/README.md +++ b/README.md @@ -1,215 +1,215 @@ -# Obsidian.md Map View - -## Intro - -This plugin introduces an **interactive map view** for the [Obsidian.md](https://obsidian.md/) editor. -It searches your notes for encoded geolocations (see below) and places them as markers on a map. - -You can set different icons for different note types, filter the displayed notes and much more. - -![](sample.png) - -![](search.png) - -This plugin is in preliminary stages, but its guiding philosophy and goal is to provide a **personal GIS system** as a complementary view for your notes. -I wrote it because I wanted my ever-growing Zettelkasten to be able to answer questions like... - -- If I'm visiting somewhere, what interesting places do I know in the area? -- If I'm planning a trip, what is the geographical relation between the points? - -And many more. - -Just like the Obsidian graph view lets you visualize associative relations between some of your notes, the map view lets you visualize geographic ones. - -## Disclaimer - -This plugin has a lot of potential for growth; it can have many more useful features (and hopefully eventually it will). -It can also be much more visually polished and much easier to use. - -However, it is the result of just a few restless evenings on which I wanted to quickly build a solution to a problem that I had. -I will not be able to give it the attention it deserves to fulfill its full potential, because it requires a lot more work. -I'm sure many will have great ideas for taking it to the next level, but unfortunately I don't expect to have the availability required for that, so at this point feature request will mostly have to go unattended. - -I believe that it can be useful enough for many users as-is, and I hope that as the user base grows, a few developers will pitch in to help continue the vision. - -## Limitations - -- Although both light & dark themes are supported, the map itself is currently only light. -- Was not yet tested & adapted to Obsidian Mobile. - -## User Guide - -### Parsing Location Data - -The plugin scans all your notes and parses two types of location data. - -First is a location tag in a note's [front matter](https://help.obsidian.md/Advanced+topics/YAML+front+matter): - -```yaml ---- -location: [40.6892494,-74.0466891] ---- -``` - -This is useful for notes that represent a single specific location. -It's also compatible with the way other useful plugins like [obsidian-leaflet](https://github.com/valentine195/obsidian-leaflet-plugin) read locations, and allows some interoperability. - -Another way that the plugin parses location data is through inline `` `location` `` markers within notes (note the backticks), which allow multiple markers in the same note. -To prevent the need to scan the full content of all your notes, it requires an empty `locations:` tag in the note front matter ('locations' and not 'location'). -Example: - -``` ---- -locations: ---- - -# Trip Plan - -Point 1: Hudson River -`location: 42.277578,-76.1598107` -... more note content ... - -Point 2: New Haven -`location: 41.2982672,-72.9991356` -``` - -Notes with multiple markers will contain multiple markers on the map with the same note name, and clicking on the marker will jump to the correct location within the note. - -Notice how locations in the front matter must contain brackets (`location: [lat, lng]`) and inline locations do not (`location: lat, lng`). -For inline locations both formats are supported, but for the front matter brackets are mandatory because it needs to compny with the YAML format. - -### Finding a Location - -If you want to log a location in a note, I recommend one of two ways. - -![](copy.png) - -1. Use one of the "copy location as..." options when you right-click the map. If you use "copy location as inline", just remember you need the note to start with a front matter that has an empty `locations:` line. - -2. Search from something in Google Maps then copy the latitude & longitude parts of the URL, e.g. if you search for "statue of liberty" you get to a link that looks like this: `https://www.google.com/maps/place/Statue+of+Liberty+National+Monument/@40.6892494,-74.0466891,17z/data=!3m1!4b1!4m5!3m4!1s0x89c25090129c363d:0x40c6a5770d25022b!8m2!3d40.6892494!4d-74.0445004`. From that you can take the location: `40.6892494,-74.0466891`. - - -### Filtering by Tags - -At the time of release, this plugin provides just one way to filter notes: an "OR" search by tags. - -Your notes are encouraged to contain Obsidian tags that represent their type (e.g. `#hike`, `#food`, `#journal-entry` or whatever you'll want to filter by). -In the search box you can type tags separated by commas and you'll get in your view just the notes that have one of these tags. - -Since this method has a single-note granularity, there is currently no way to see just a few locations inlined in the same note. -If a note's tag is included in the search, all the locations within this note will be displayed on the map. - -### Marker Icons - -Although there isn't yet a friendly way in the GUI to configure this, the plugin allows you to selectively apply icons to notes based on a powerful rules system. - -To understand how this works you'll first have to refer to the [Leaflet.ExtraMarkers](https://github.com/coryasilva/Leaflet.ExtraMarkers#icons) package and use icon names from [Font Awesome](https://fontawesome.com/). - -A single marker is defined in the following JSON structure: -`{"prefix": "fas", "icon": "fa-bus", "shape": "circle", "color": "red"}` - -To build this, I searched Font Awesome (in the link above) for 'bus' and chose [this icon](https://fontawesome.com/v5.15/icons/bus?style=solid). -A Font Awesome icon has a style prefix (in this case `fas`) and an icon name that always starts with `fa`, in this case `fa-bus`. -Shape and color are for your choosing. - -#### Tag Rules - -To apply an icon to a note with geolocation data, Map View scans a list of rules. -You can edit these rules through the plugin configuration, which currently includes a not-so-friendly JSON dictionary that you need to carefully edit. -Please don't do that if you're unfamiliar with the JSON syntax, if you wait a while I'm sure that a better GUI will be built :) - -Map View scans the rules and applies them one by one, always starting from `default` and then from first to last. A rule matches if the tag that it lists is included in the note, and then the rule's fields will overwrite the corresponding fields of the previous matching rules, until all rules were scanned. -This allows you to set rules that change just some properties of the icons, e.g. some rules change the shape according to some tags, some change the color etc. - -Here's the example I provide as a probably-not-useful default in the plugin: - -```json - { - "default": {"prefix": "fas", "icon": "fa-circle", "markerColor": "blue"}, - "#trip": {"prefix": "fas", "icon": "fa-hiking", "markerColor": "green"}, - "#trip-water": {"prefix": "fas", "markerColor": "blue"}, - "#dogs": {"prefix": "fas", "icon": "fa-paw"}, - } -``` - -This means that all notes will have a blue `fa-circle` icon by default. -However, a note with the `#trip` tag will have a green `fa-hiking` icon. -Then, a note that has both the `#trip` and `#trip-water` tags will have a `fa-hiking` marker (when the `#trip` rule is applied), but a **blue** marker, because the `#trip-water` overwrites the `markerColor` that the previous `#trip` rule has set. - -**Consider copying the configuration to an external editor and editing it there.** -The configuration dialog ignores an invalid JSON object, so if you close it in a state that has a syntax error, your changes will be lost. - -### Map Sources - -By default, Map View uses the [standard tile layer of OpenStreetMap](https://wiki.openstreetmap.org/wiki/Standard_tile_layer). -However, you can change the map source in the configuration to any service that has a tiles API using a standard URL syntax. - -There are many services of localized, specialized or just beautifully-rendered maps that you can use, sometimes following a free registration. -See a pretty comprehensive list [here](https://wiki.openstreetmap.org/wiki/Tiles). - -Although that's the case with this plugin in general, it's worth noting explicitly that using 3rd party map data properly, and making sure you are not violating any terms of use, is your own responsibility. - -Note that Google Maps is not in that list, because although it does provide the same standard form of static tiles in the same URL format, the Google Maps terms of service makes it difficult to legally bundle the maps in an application. - -## Relation to Other Obsidian Plugins - -When thinking about Obsidian and maps, the first plugin that comes to mind is [Obsidian Leaflet](https://github.com/valentine195/obsidian-leaflet-plugin). -That plugin is great at rendering maps based on data within a note, with great customization options. -It can also scan for data inside a directory which gives even more power. -In contrast, Obsidian Map View is focused on showing and interacting with your notes geographically. - -Another relevant plugin is [Obsidian Map](https://github.com/Darakah/obsidian-map) which seems to focus on powerful tools for map drawing. - -## Wishlist - -As noted in the disclaimer above, my wishlist for this plugin is huge and I'm unlikely to get to it all. -There are so many things that I want it to do, and so little time... - -- **Most importantly**: proper mobile support including device location if possible. That's literally on the top of my list. -- More powerful filtering. I'd love it to be based on the [existing Obsidian query format](https://github.com/obsidianmd/obsidian-api/issues/22). What I see in mind is a powerful text search with a results pane that's linked to the map. -- Better interoperability with Obsidian Leaflet: support for marker image files, locations as an array and `marker` tags. -- Better UI, especially for the core functionality like editing icons. -- Dark mode. -- A side bar with note summaries linked to the map view. - -## Changelog - -### 0.0.8 - -- Fixed [a bug](https://github.com/esm7/obsidian-map-view/issues/12) allowing to confusingly add markers out of earth's proper bounds. -- "New note here" right-click option with configuration options. -- Markers now updated dynamically when relevant notes are added/deleted/modified. -- Tweaks to opening notes in a 2nd pane (be able to use a 2nd pane if it already existed). -- When jumping to a location within a note, the corresponding note line is now highlighted. -- "Open in Google Maps" menu item within notes with locations (both note menu and right-click on a location). - -### 0.0.7 - -Tiny fix to an annoying bug of the default not being applied. - -### 0.0.6 - -Small fixes before the plugin formal release. - -### 0.0.5 - -- New "show on map" menu item in the editor. -- Fixed a nasty compatibility issue with obsidian-leaflet, see [here](https://github.com/esm7/obsidian-map-view/issues/6). - -### 0.0.4 - -- Added settings (and Ctrl key) to open a note in a separate pane (https://github.com/esm7/obsidian-map-view/issues/3). - -### 0.0.3 - -- Proper view and state management (hopefully). -- Fixed a bug in location parsing. - -### 0.0.2 - -Various cleanups, better copyright handling and generally more readiness for releasing the plugin. - -### 0.0.1 - -Initial alpha release. - +# Obsidian.md Map View + +## Intro + +This plugin introduces an **interactive map view** for the [Obsidian.md](https://obsidian.md/) editor. +It searches your notes for encoded geolocations (see below) and places them as markers on a map. + +You can set different icons for different note types, filter the displayed notes and much more. + +![](sample.png) + +![](search.png) + +This plugin is in preliminary stages, but its guiding philosophy and goal is to provide a **personal GIS system** as a complementary view for your notes. +I wrote it because I wanted my ever-growing Zettelkasten to be able to answer questions like... + +- If I'm visiting somewhere, what interesting places do I know in the area? +- If I'm planning a trip, what is the geographical relation between the points? + +And many more. + +Just like the Obsidian graph view lets you visualize associative relations between some of your notes, the map view lets you visualize geographic ones. + +## Disclaimer + +This plugin has a lot of potential for growth; it can have many more useful features (and hopefully eventually it will). +It can also be much more visually polished and much easier to use. + +However, it is the result of just a few restless evenings on which I wanted to quickly build a solution to a problem that I had. +I will not be able to give it the attention it deserves to fulfill its full potential, because it requires a lot more work. +I'm sure many will have great ideas for taking it to the next level, but unfortunately I don't expect to have the availability required for that, so at this point feature request will mostly have to go unattended. + +I believe that it can be useful enough for many users as-is, and I hope that as the user base grows, a few developers will pitch in to help continue the vision. + +## Limitations + +- Although both light & dark themes are supported, the map itself is currently only light. +- Was not yet tested & adapted to Obsidian Mobile. + +## User Guide + +### Parsing Location Data + +The plugin scans all your notes and parses two types of location data. + +First is a location tag in a note's [front matter](https://help.obsidian.md/Advanced+topics/YAML+front+matter): + +```yaml +--- +location: [40.6892494,-74.0466891] +--- +``` + +This is useful for notes that represent a single specific location. +It's also compatible with the way other useful plugins like [obsidian-leaflet](https://github.com/valentine195/obsidian-leaflet-plugin) read locations, and allows some interoperability. + +Another way that the plugin parses location data is through inline `` `location` `` markers within notes (note the backticks), which allow multiple markers in the same note. +To prevent the need to scan the full content of all your notes, it requires an empty `locations:` tag in the note front matter ('locations' and not 'location'). +Example: + +``` +--- +locations: +--- + +# Trip Plan + +Point 1: Hudson River +`location: 42.277578,-76.1598107` +... more note content ... + +Point 2: New Haven +`location: 41.2982672,-72.9991356` +``` + +Notes with multiple markers will contain multiple markers on the map with the same note name, and clicking on the marker will jump to the correct location within the note. + +Notice how locations in the front matter must contain brackets (`location: [lat, lng]`) and inline locations do not (`location: lat, lng`). +For inline locations both formats are supported, but for the front matter brackets are mandatory because it needs to compny with the YAML format. + +### Finding a Location + +If you want to log a location in a note, I recommend one of two ways. + +![](copy.png) + +1. Use one of the "copy location as..." options when you right-click the map. If you use "copy location as inline", just remember you need the note to start with a front matter that has an empty `locations:` line. + +2. Search from something in Google Maps then copy the latitude & longitude parts of the URL, e.g. if you search for "statue of liberty" you get to a link that looks like this: `https://www.google.com/maps/place/Statue+of+Liberty+National+Monument/@40.6892494,-74.0466891,17z/data=!3m1!4b1!4m5!3m4!1s0x89c25090129c363d:0x40c6a5770d25022b!8m2!3d40.6892494!4d-74.0445004`. From that you can take the location: `40.6892494,-74.0466891`. + + +### Filtering by Tags + +At the time of release, this plugin provides just one way to filter notes: an "OR" search by tags. + +Your notes are encouraged to contain Obsidian tags that represent their type (e.g. `#hike`, `#food`, `#journal-entry` or whatever you'll want to filter by). +In the search box you can type tags separated by commas and you'll get in your view just the notes that have one of these tags. + +Since this method has a single-note granularity, there is currently no way to see just a few locations inlined in the same note. +If a note's tag is included in the search, all the locations within this note will be displayed on the map. + +### Marker Icons + +Although there isn't yet a friendly way in the GUI to configure this, the plugin allows you to selectively apply icons to notes based on a powerful rules system. + +To understand how this works you'll first have to refer to the [Leaflet.ExtraMarkers](https://github.com/coryasilva/Leaflet.ExtraMarkers#icons) package and use icon names from [Font Awesome](https://fontawesome.com/). + +A single marker is defined in the following JSON structure: +`{"prefix": "fas", "icon": "fa-bus", "shape": "circle", "color": "red"}` + +To build this, I searched Font Awesome (in the link above) for 'bus' and chose [this icon](https://fontawesome.com/v5.15/icons/bus?style=solid). +A Font Awesome icon has a style prefix (in this case `fas`) and an icon name that always starts with `fa`, in this case `fa-bus`. +Shape and color are for your choosing. + +#### Tag Rules + +To apply an icon to a note with geolocation data, Map View scans a list of rules. +You can edit these rules through the plugin configuration, which currently includes a not-so-friendly JSON dictionary that you need to carefully edit. +Please don't do that if you're unfamiliar with the JSON syntax, if you wait a while I'm sure that a better GUI will be built :) + +Map View scans the rules and applies them one by one, always starting from `default` and then from first to last. A rule matches if the tag that it lists is included in the note, and then the rule's fields will overwrite the corresponding fields of the previous matching rules, until all rules were scanned. +This allows you to set rules that change just some properties of the icons, e.g. some rules change the shape according to some tags, some change the color etc. + +Here's the example I provide as a probably-not-useful default in the plugin: + +```json + { + "default": {"prefix": "fas", "icon": "fa-circle", "markerColor": "blue"}, + "#trip": {"prefix": "fas", "icon": "fa-hiking", "markerColor": "green"}, + "#trip-water": {"prefix": "fas", "markerColor": "blue"}, + "#dogs": {"prefix": "fas", "icon": "fa-paw"}, + } +``` + +This means that all notes will have a blue `fa-circle` icon by default. +However, a note with the `#trip` tag will have a green `fa-hiking` icon. +Then, a note that has both the `#trip` and `#trip-water` tags will have a `fa-hiking` marker (when the `#trip` rule is applied), but a **blue** marker, because the `#trip-water` overwrites the `markerColor` that the previous `#trip` rule has set. + +**Consider copying the configuration to an external editor and editing it there.** +The configuration dialog ignores an invalid JSON object, so if you close it in a state that has a syntax error, your changes will be lost. + +### Map Sources + +By default, Map View uses the [standard tile layer of OpenStreetMap](https://wiki.openstreetmap.org/wiki/Standard_tile_layer). +However, you can change the map source in the configuration to any service that has a tiles API using a standard URL syntax. + +There are many services of localized, specialized or just beautifully-rendered maps that you can use, sometimes following a free registration. +See a pretty comprehensive list [here](https://wiki.openstreetmap.org/wiki/Tiles). + +Although that's the case with this plugin in general, it's worth noting explicitly that using 3rd party map data properly, and making sure you are not violating any terms of use, is your own responsibility. + +Note that Google Maps is not in that list, because although it does provide the same standard form of static tiles in the same URL format, the Google Maps terms of service makes it difficult to legally bundle the maps in an application. + +## Relation to Other Obsidian Plugins + +When thinking about Obsidian and maps, the first plugin that comes to mind is [Obsidian Leaflet](https://github.com/valentine195/obsidian-leaflet-plugin). +That plugin is great at rendering maps based on data within a note, with great customization options. +It can also scan for data inside a directory which gives even more power. +In contrast, Obsidian Map View is focused on showing and interacting with your notes geographically. + +Another relevant plugin is [Obsidian Map](https://github.com/Darakah/obsidian-map) which seems to focus on powerful tools for map drawing. + +## Wishlist + +As noted in the disclaimer above, my wishlist for this plugin is huge and I'm unlikely to get to it all. +There are so many things that I want it to do, and so little time... + +- **Most importantly**: proper mobile support including device location if possible. That's literally on the top of my list. +- More powerful filtering. I'd love it to be based on the [existing Obsidian query format](https://github.com/obsidianmd/obsidian-api/issues/22). What I see in mind is a powerful text search with a results pane that's linked to the map. +- Better interoperability with Obsidian Leaflet: support for marker image files, locations as an array and `marker` tags. +- Better UI, especially for the core functionality like editing icons. +- Dark mode. +- A side bar with note summaries linked to the map view. + +## Changelog + +### 0.0.8 + +- Fixed [a bug](https://github.com/esm7/obsidian-map-view/issues/12) allowing to confusingly add markers out of earth's proper bounds. +- "New note here" right-click option with configuration options. +- Markers now updated dynamically when relevant notes are added/deleted/modified. +- Tweaks to opening notes in a 2nd pane (be able to use a 2nd pane if it already existed). +- When jumping to a location within a note, the corresponding note line is now highlighted. +- "Open in Google Maps" menu item within notes with locations (both note menu and right-click on a location). + +### 0.0.7 + +Tiny fix to an annoying bug of the default not being applied. + +### 0.0.6 + +Small fixes before the plugin formal release. + +### 0.0.5 + +- New "show on map" menu item in the editor. +- Fixed a nasty compatibility issue with obsidian-leaflet, see [here](https://github.com/esm7/obsidian-map-view/issues/6). + +### 0.0.4 + +- Added settings (and Ctrl key) to open a note in a separate pane (https://github.com/esm7/obsidian-map-view/issues/3). + +### 0.0.3 + +- Proper view and state management (hopefully). +- Fixed a bug in location parsing. + +### 0.0.2 + +Various cleanups, better copyright handling and generally more readiness for releasing the plugin. + +### 0.0.1 + +Initial alpha release. + diff --git a/manifest.json b/manifest.json index 6301714..81537ba 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ -{ - "id": "obsidian-map-view", - "name": "Map View", - "version": "0.0.8", - "minAppVersion": "0.12.10", - "description": "An interactive map view.", - "isDesktopOnly": false -} +{ + "id": "obsidian-map-view", + "name": "Map View", + "version": "0.0.8", + "minAppVersion": "0.12.10", + "description": "An interactive map view.", + "isDesktopOnly": false +} diff --git a/rollup.config.js b/rollup.config.js index 6df211e..525a66c 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,39 +1,39 @@ -import typescript from '@rollup/plugin-typescript'; -import { nodeResolve } from '@rollup/plugin-node-resolve'; -import commonjs from '@rollup/plugin-commonjs'; -import postcss from 'rollup-plugin-postcss'; -import postcss_url from 'postcss-url'; -import copy from 'rollup-plugin-copy'; - -const isProd = (process.env.BUILD === 'production'); - -const banner = - `/* -THIS IS A GENERATED/BUNDLED FILE BY ROLLUP -if you want to view the source visit the plugins github repository -*/ -`; - -export default { - input: 'src/main.ts', - output: { - dir: './dist', - sourcemap: isProd ? false : 'inline', - sourcemapExcludeSources: isProd, - format: 'cjs', - exports: 'default', - banner, - }, - external: ['obsidian'], - plugins: [ - typescript(), - nodeResolve({ browser: true }), - commonjs(), - postcss({ extensions: ['.css'], plugins: [postcss_url({ url: 'inline' })] }), - copy({ - targets: [ - { src: './manifest.json', dest: 'dist' } - ] - }) - ] +import typescript from '@rollup/plugin-typescript'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import postcss from 'rollup-plugin-postcss'; +import postcss_url from 'postcss-url'; +import copy from 'rollup-plugin-copy'; + +const isProd = (process.env.BUILD === 'production'); + +const banner = + `/* +THIS IS A GENERATED/BUNDLED FILE BY ROLLUP +if you want to view the source visit the plugins github repository +*/ +`; + +export default { + input: 'src/main.ts', + output: { + dir: './dist', + sourcemap: isProd ? false : 'inline', + sourcemapExcludeSources: isProd, + format: 'cjs', + exports: 'default', + banner, + }, + external: ['obsidian'], + plugins: [ + typescript(), + nodeResolve({ browser: true }), + commonjs(), + postcss({ extensions: ['.css'], plugins: [postcss_url({ url: 'inline' })] }), + copy({ + targets: [ + { src: './manifest.json', dest: 'dist' } + ] + }) + ] }; \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 7b1ecba..9177a3f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,321 +1,321 @@ -import { addIcon, App, Editor, FileView, MarkdownView, MenuItem, Menu, TFile, Plugin, WorkspaceLeaf, PluginSettingTab, Setting, TAbstractFile } from 'obsidian'; -import * as consts from 'src/consts'; -import * as leaflet from 'leaflet'; - -import { MapView } from 'src/mapView'; -import { PluginSettings, DEFAULT_SETTINGS, isImage } from 'src/settings'; -import { FileMarker, markersFromMd, getFrontMatterLocation, getIconFromOptions, matchInlineLocation, verifyLocation } from 'src/markers'; -import exifr from "exifr"; - -type Extractor = (f: TFile) => Promise; -type Predicate = (f: TFile) => boolean; -type Rule = {pred:Predicate, extract:Extractor}; - - -export default class MapViewPlugin extends Plugin { - settings: PluginSettings; - - CACHE_RULES: Rule[] = [ - { pred: (f: TFile) => this.settings.detectImageLocations && isImage(f), extract: (f: TFile) => this.getImageCoords(f) }, - { pred: (f: TFile) => f.extension == 'md', extract: (f: TFile) => markersFromMd(f, this.settings, this.app) }, - ]; - private _fileCache: Map; - private *cacheGen () { for (let val of this._fileCache.values()) yield Promise.resolve(val[2]); } - // BUG: sometimes this gets called twice before the cache has been populated, which parses all the files multiple times (only affects performance) - public get fileCache() { - if (!this._fileCache) return this.cacheAdd(); - else return this.cacheGen(); - } - - async onload() { - addIcon('globe', consts.RIBBON_ICON); - - await this.loadSettings(); - - this.addRibbonIcon('globe', 'Open map view', () => { - this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); - }); - - this.registerView(consts.MAP_VIEW_NAME, (leaf: WorkspaceLeaf) => { - return new MapView(leaf, this.settings, this); - }); - - this.addCommand({ - id: 'open-map-view', - name: 'Open Map View', - callback: () => { - this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); - }, - }); - - this.addSettingTab(new SettingsTab(this.app, this)); - - this.app.workspace.on('file-menu', (menu: Menu, file: TAbstractFile, _source: string, leaf?: WorkspaceLeaf) => { - if (file instanceof TFile) { - const location = getFrontMatterLocation(file, this.app); - if (location) { - menu.addItem((item: MenuItem) => { - item.setTitle('Show on map'); - item.setIcon('globe'); - item.onClick(() => this.openMapWithLocation(location)); - }); - menu.addItem((item: MenuItem) => { - item.setTitle('Open in Google Maps'); - item.onClick(_ev => { - open(`https://maps.google.com/?q=${location.lat},${location.lng}`); - }); - }); - } - } - }); - - // TODO function signature is a guess, revise when API is released - // @ts-ignore - this.app.workspace.on('editor-menu', (menu: Menu, editor: Editor, view: FileView) => { - if (view instanceof FileView) { - const location = this.getLocationOnEditorLine(editor, view); - if (location) { - menu.addItem((item: MenuItem) => { - item.setTitle('Show on map'); - item.setIcon('globe'); - item.onClick(() => this.openMapWithLocation(location)); - }); - menu.addItem((item: MenuItem) => { - item.setTitle('Open in Google Maps'); - item.onClick(_ev => { - open(`https://maps.google.com/?q=${location.lat},${location.lng}`); - }); - }); - } - } - }); - this.app.vault.on('delete',file => this.cacheRemove(file)); - } - - private async openMapWithLocation(location: leaflet.LatLng) { - await this.app.workspace.getLeaf().setViewState({ - type: consts.MAP_VIEW_NAME, - state: { - mapCenter: location, - mapZoom: this.settings.zoomOnGoFromNote - } as any - }); - } - - private getLocationOnEditorLine(editor: Editor, view: FileView): leaflet.LatLng { - const line = editor.getLine(editor.getCursor().line); - const match = matchInlineLocation(line)?.next()?.value; - let selectedLocation = null; - if (match) - selectedLocation = new leaflet.LatLng(parseFloat(match[1]), parseFloat(match[2])); - else { - const fmLocation = getFrontMatterLocation(view.file, this.app); - if (line.indexOf('location') > -1 && fmLocation) - selectedLocation = fmLocation; - } - if (selectedLocation) { - verifyLocation(selectedLocation); - return selectedLocation; - } - return null; - } - - onunload() { - } - - async loadSettings() { - //TODO: loading and saving the cache from/to a json file would make the map ready faster for large vaults - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - } - - async saveSettings() { - await this.saveData(this.settings); - } - - async getImageCoords(file: TFile) { - try{ - let { latitude, longitude } = await exifr.parse(await this.app.vault.adapter.readBinary(file.path)); - if(!latitude || !longitude) throw 0; - let leafletMarker = new FileMarker(file, new leaflet.LatLng(latitude, longitude)); - leafletMarker.icon = this.getImageMarker(this.settings); - return leafletMarker; - } catch{/* just ignore file parsing errors or empty location data for now*/} - return null; - } - - //TODO: as more filetypes are supported, icons should be configurable for each - getImageMarker(settings: PluginSettings) { - return getIconFromOptions(Object.assign({}, settings.markerIcons.default, { "prefix": "fas", "icon": "fa-camera" })); - } - - // TODO: should probably make the cache it's own class - public cacheGet(file: TFile) { return this._fileCache.get(file.path)[2]; } - public cacheRemove(...files: TAbstractFile[]) { if (files?.length) for (let file of files) this._fileCache.delete(file.path); } - async cacheReset() { - this._fileCache = null; - for (let f of this.cacheAdd()); - } - - *cacheAdd(...files: TFile[]) { - let add = async (file: TFile) => { - let existing = this._fileCache.get(file.path); - if (existing) return existing[2]; - for (const rule of this.CACHE_RULES) - if (rule.pred(file)) { - let marker = await rule.extract(file); - if (marker) { - this._fileCache.set(file.path, [file, rule, marker]); - return marker; - } - } - return null; - }; - if (!this._fileCache) this._fileCache = new Map(); - if (files?.length) for (let file of files) yield add(file); - else { - console.log('Loading cache...'); - // md files will be faster to extract from so we do them first - let markdownFilesFirst = this.app.vault.getFiles().sort((a, b) => - a.extension == b.extension ? 0 : a.extension == 'md' ? 1 : -1 - ); - for (let file of markdownFilesFirst) yield add(file); - } - } -} - -class SettingsTab extends PluginSettingTab { - plugin: MapViewPlugin; - - constructor(app: App, plugin: MapViewPlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - let { containerEl } = this; - - containerEl.empty(); - - containerEl.createEl('h2', { text: 'Settings for the map view plugin.' }); - - new Setting(containerEl) - .setName('Automatically add markers for images') - .setDesc('Search the vault for image files and add markers on the map if they have location data') - .addToggle(component => { - component - .setValue(this.plugin.settings.detectImageLocations) - .onChange(async (value) => { - this.plugin.settings.detectImageLocations = value; - this.plugin.cacheReset(); - await this.plugin.saveSettings(); - }); - }); - - new Setting(containerEl) - .setName('Map follows search results') - .setDesc('Auto focus the map to fit search results.') - .addToggle(component => {component - .setValue(this.plugin.settings.autoZoom) - .onChange(async (value) => { - this.plugin.settings.autoZoom = value; - await this.plugin.saveSettings(); - }) - }); - - new Setting(containerEl) - .setName('Default action for map marker click') - .setDesc('How should the corresponding note be opened when clicking a map marker? Either way, CTRL reverses the behavior.') - .addDropdown(component => { component - .addOption('samePane', 'Open in same pane (replace map view)') - .addOption('secondPane', 'Open in a 2nd pane and keep reusing it') - .addOption('alwaysNew', 'Always open a new pane') - .setValue(this.plugin.settings.markerClickBehavior || 'samePane') - .onChange(async (value: any) => { - this.plugin.settings.markerClickBehavior = value; - this.plugin.saveSettings(); - }) - }); - - new Setting(containerEl) - .setName('New pane split direction') - .setDesc('Which way should the pane be split when opening in a new pane.') - .addDropdown(component => { component - .addOption('horizontal', 'Horizontal') - .addOption('vertical', 'Vertical') - .setValue(this.plugin.settings.newPaneSplitDirection || 'horizontal') - .onChange(async (value: any) => { - this.plugin.settings.newPaneSplitDirection = value; - this.plugin.saveSettings(); - }) - }); - - new Setting(containerEl) - .setName('New note name format') - .setDesc('Date/times in the format can be wrapped in {{date:...}}, e.g. "note-{{date:YYYY-MM-DD}}".') - .addText(component => { component - .setValue(this.plugin.settings.newNoteNameFormat || DEFAULT_SETTINGS.newNoteNameFormat) - .onChange(async (value: string) => { - this.plugin.settings.newNoteNameFormat = value; - this.plugin.saveSettings(); - }) - }); - new Setting(containerEl) - .setName('New note location') - .setDesc('Location for notes created from the map.') - .addText(component => { component - .setValue(this.plugin.settings.newNotePath || '') - .onChange(async (value: string) => { - this.plugin.settings.newNotePath = value; - this.plugin.saveSettings(); - }) - }); - new Setting(containerEl) - .setName('Template file location') - .setDesc('Choose the file to use as a template, e.g. "templates/map-log.md".') - .addText(component => { component - .setValue(this.plugin.settings.newNoteTemplate || '') - .onChange(async (value: string) => { - this.plugin.settings.newNoteTemplate = value; - this.plugin.saveSettings(); - }) - }); - - new Setting(containerEl) - .setName('Default zoom for "show on map" action') - .setDesc('When jumping to the map from a note, what should be the display zoom?') - .addSlider(component => {component - .setLimits(1, 18, 1) - .setValue(this.plugin.settings.zoomOnGoFromNote) - .onChange(async (value) => { - this.plugin.settings.zoomOnGoFromNote = value; - await this.plugin.saveSettings(); - }) - }); - - new Setting(containerEl) - .setName('Map source (advanced)') - .setDesc('Source for the map tiles, see the documentation for more details. Requires to close & reopen the map.') - .addText(component => {component - .setValue(this.plugin.settings.tilesUrl) - .onChange(async (value) => { - this.plugin.settings.tilesUrl = value; - await this.plugin.saveSettings(); - }) - }); - - new Setting(containerEl) - .setName('Edit the marker icons (advanced)') - .setDesc("Refer to the plugin documentation for more details.") - .addTextArea(component => component - .setValue(JSON.stringify(this.plugin.settings.markerIcons, null, 2)) - .onChange(async value => { - try { - const newMarkerIcons = JSON.parse(value); - this.plugin.settings.markerIcons = newMarkerIcons; - await this.plugin.saveSettings(); - } catch (e) { - } - })); - - } -} +import { addIcon, App, Editor, FileView, MarkdownView, MenuItem, Menu, TFile, Plugin, WorkspaceLeaf, PluginSettingTab, Setting, TAbstractFile } from 'obsidian'; +import * as consts from 'src/consts'; +import * as leaflet from 'leaflet'; + +import { MapView } from 'src/mapView'; +import { PluginSettings, DEFAULT_SETTINGS, isImage } from 'src/settings'; +import { FileMarker, markersFromMd, getFrontMatterLocation, getIconFromOptions, matchInlineLocation, verifyLocation } from 'src/markers'; +import exifr from "exifr"; + +type Extractor = (f: TFile) => Promise; +type Predicate = (f: TFile) => boolean; +type Rule = {pred:Predicate, extract:Extractor}; + + +export default class MapViewPlugin extends Plugin { + settings: PluginSettings; + + CACHE_RULES: Rule[] = [ + { pred: (f: TFile) => this.settings.detectImageLocations && isImage(f), extract: (f: TFile) => this.getImageCoords(f) }, + { pred: (f: TFile) => f.extension == 'md', extract: (f: TFile) => markersFromMd(f, this.settings, this.app) }, + ]; + private _fileCache: Map; + private *cacheGen () { for (let val of this._fileCache.values()) yield Promise.resolve(val[2]); } + // BUG: sometimes this gets called twice before the cache has been populated, which parses all the files multiple times (only affects performance) + public get fileCache() { + if (!this._fileCache) return this.cacheAdd(); + else return this.cacheGen(); + } + + async onload() { + addIcon('globe', consts.RIBBON_ICON); + + await this.loadSettings(); + + this.addRibbonIcon('globe', 'Open map view', () => { + this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); + }); + + this.registerView(consts.MAP_VIEW_NAME, (leaf: WorkspaceLeaf) => { + return new MapView(leaf, this.settings, this); + }); + + this.addCommand({ + id: 'open-map-view', + name: 'Open Map View', + callback: () => { + this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME}); + }, + }); + + this.addSettingTab(new SettingsTab(this.app, this)); + + this.app.workspace.on('file-menu', (menu: Menu, file: TAbstractFile, _source: string, leaf?: WorkspaceLeaf) => { + if (file instanceof TFile) { + const location = getFrontMatterLocation(file, this.app); + if (location) { + menu.addItem((item: MenuItem) => { + item.setTitle('Show on map'); + item.setIcon('globe'); + item.onClick(() => this.openMapWithLocation(location)); + }); + menu.addItem((item: MenuItem) => { + item.setTitle('Open in Google Maps'); + item.onClick(_ev => { + open(`https://maps.google.com/?q=${location.lat},${location.lng}`); + }); + }); + } + } + }); + + // TODO function signature is a guess, revise when API is released + // @ts-ignore + this.app.workspace.on('editor-menu', (menu: Menu, editor: Editor, view: FileView) => { + if (view instanceof FileView) { + const location = this.getLocationOnEditorLine(editor, view); + if (location) { + menu.addItem((item: MenuItem) => { + item.setTitle('Show on map'); + item.setIcon('globe'); + item.onClick(() => this.openMapWithLocation(location)); + }); + menu.addItem((item: MenuItem) => { + item.setTitle('Open in Google Maps'); + item.onClick(_ev => { + open(`https://maps.google.com/?q=${location.lat},${location.lng}`); + }); + }); + } + } + }); + this.app.vault.on('delete',file => this.cacheRemove(file)); + } + + private async openMapWithLocation(location: leaflet.LatLng) { + await this.app.workspace.getLeaf().setViewState({ + type: consts.MAP_VIEW_NAME, + state: { + mapCenter: location, + mapZoom: this.settings.zoomOnGoFromNote + } as any + }); + } + + private getLocationOnEditorLine(editor: Editor, view: FileView): leaflet.LatLng { + const line = editor.getLine(editor.getCursor().line); + const match = matchInlineLocation(line)?.next()?.value; + let selectedLocation = null; + if (match) + selectedLocation = new leaflet.LatLng(parseFloat(match[1]), parseFloat(match[2])); + else { + const fmLocation = getFrontMatterLocation(view.file, this.app); + if (line.indexOf('location') > -1 && fmLocation) + selectedLocation = fmLocation; + } + if (selectedLocation) { + verifyLocation(selectedLocation); + return selectedLocation; + } + return null; + } + + onunload() { + } + + async loadSettings() { + //TODO: loading and saving the cache from/to a json file would make the map ready faster for large vaults + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings() { + await this.saveData(this.settings); + } + + async getImageCoords(file: TFile) { + try{ + let { latitude, longitude } = await exifr.parse(await this.app.vault.adapter.readBinary(file.path)); + if(!latitude || !longitude) throw 0; + let leafletMarker = new FileMarker(file, new leaflet.LatLng(latitude, longitude)); + leafletMarker.icon = this.getImageMarker(this.settings); + return leafletMarker; + } catch{/* just ignore file parsing errors or empty location data for now*/} + return null; + } + + //TODO: as more filetypes are supported, icons should be configurable for each + getImageMarker(settings: PluginSettings) { + return getIconFromOptions(Object.assign({}, settings.markerIcons.default, { "prefix": "fas", "icon": "fa-camera" })); + } + + // TODO: should probably make the cache it's own class + public cacheGet(file: TFile) { return this._fileCache.get(file.path)[2]; } + public cacheRemove(...files: TAbstractFile[]) { if (files?.length) for (let file of files) this._fileCache.delete(file.path); } + async cacheReset() { + this._fileCache = null; + for (let f of this.cacheAdd()); + } + + *cacheAdd(...files: TFile[]) { + let add = async (file: TFile) => { + let existing = this._fileCache.get(file.path); + if (existing) return existing[2]; + for (const rule of this.CACHE_RULES) + if (rule.pred(file)) { + let marker = await rule.extract(file); + if (marker) { + this._fileCache.set(file.path, [file, rule, marker]); + return marker; + } + } + return null; + }; + if (!this._fileCache) this._fileCache = new Map(); + if (files?.length) for (let file of files) yield add(file); + else { + console.log('Loading cache...'); + // md files will be faster to extract from so we do them first + let markdownFilesFirst = this.app.vault.getFiles().sort((a, b) => + a.extension == b.extension ? 0 : a.extension == 'md' ? 1 : -1 + ); + for (let file of markdownFilesFirst) yield add(file); + } + } +} + +class SettingsTab extends PluginSettingTab { + plugin: MapViewPlugin; + + constructor(app: App, plugin: MapViewPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + let { containerEl } = this; + + containerEl.empty(); + + containerEl.createEl('h2', { text: 'Settings for the map view plugin.' }); + + new Setting(containerEl) + .setName('Automatically add markers for images') + .setDesc('Search the vault for image files and add markers on the map if they have location data') + .addToggle(component => { + component + .setValue(this.plugin.settings.detectImageLocations) + .onChange(async (value) => { + this.plugin.settings.detectImageLocations = value; + this.plugin.cacheReset(); + await this.plugin.saveSettings(); + }); + }); + + new Setting(containerEl) + .setName('Map follows search results') + .setDesc('Auto focus the map to fit search results.') + .addToggle(component => {component + .setValue(this.plugin.settings.autoZoom) + .onChange(async (value) => { + this.plugin.settings.autoZoom = value; + await this.plugin.saveSettings(); + }) + }); + + new Setting(containerEl) + .setName('Default action for map marker click') + .setDesc('How should the corresponding note be opened when clicking a map marker? Either way, CTRL reverses the behavior.') + .addDropdown(component => { component + .addOption('samePane', 'Open in same pane (replace map view)') + .addOption('secondPane', 'Open in a 2nd pane and keep reusing it') + .addOption('alwaysNew', 'Always open a new pane') + .setValue(this.plugin.settings.markerClickBehavior || 'samePane') + .onChange(async (value: any) => { + this.plugin.settings.markerClickBehavior = value; + this.plugin.saveSettings(); + }) + }); + + new Setting(containerEl) + .setName('New pane split direction') + .setDesc('Which way should the pane be split when opening in a new pane.') + .addDropdown(component => { component + .addOption('horizontal', 'Horizontal') + .addOption('vertical', 'Vertical') + .setValue(this.plugin.settings.newPaneSplitDirection || 'horizontal') + .onChange(async (value: any) => { + this.plugin.settings.newPaneSplitDirection = value; + this.plugin.saveSettings(); + }) + }); + + new Setting(containerEl) + .setName('New note name format') + .setDesc('Date/times in the format can be wrapped in {{date:...}}, e.g. "note-{{date:YYYY-MM-DD}}".') + .addText(component => { component + .setValue(this.plugin.settings.newNoteNameFormat || DEFAULT_SETTINGS.newNoteNameFormat) + .onChange(async (value: string) => { + this.plugin.settings.newNoteNameFormat = value; + this.plugin.saveSettings(); + }) + }); + new Setting(containerEl) + .setName('New note location') + .setDesc('Location for notes created from the map.') + .addText(component => { component + .setValue(this.plugin.settings.newNotePath || '') + .onChange(async (value: string) => { + this.plugin.settings.newNotePath = value; + this.plugin.saveSettings(); + }) + }); + new Setting(containerEl) + .setName('Template file location') + .setDesc('Choose the file to use as a template, e.g. "templates/map-log.md".') + .addText(component => { component + .setValue(this.plugin.settings.newNoteTemplate || '') + .onChange(async (value: string) => { + this.plugin.settings.newNoteTemplate = value; + this.plugin.saveSettings(); + }) + }); + + new Setting(containerEl) + .setName('Default zoom for "show on map" action') + .setDesc('When jumping to the map from a note, what should be the display zoom?') + .addSlider(component => {component + .setLimits(1, 18, 1) + .setValue(this.plugin.settings.zoomOnGoFromNote) + .onChange(async (value) => { + this.plugin.settings.zoomOnGoFromNote = value; + await this.plugin.saveSettings(); + }) + }); + + new Setting(containerEl) + .setName('Map source (advanced)') + .setDesc('Source for the map tiles, see the documentation for more details. Requires to close & reopen the map.') + .addText(component => {component + .setValue(this.plugin.settings.tilesUrl) + .onChange(async (value) => { + this.plugin.settings.tilesUrl = value; + await this.plugin.saveSettings(); + }) + }); + + new Setting(containerEl) + .setName('Edit the marker icons (advanced)') + .setDesc("Refer to the plugin documentation for more details.") + .addTextArea(component => component + .setValue(JSON.stringify(this.plugin.settings.markerIcons, null, 2)) + .onChange(async value => { + try { + const newMarkerIcons = JSON.parse(value); + this.plugin.settings.markerIcons = newMarkerIcons; + await this.plugin.saveSettings(); + } catch (e) { + } + })); + + } +} diff --git a/tsconfig.json b/tsconfig.json index c1266ea..c4f6ec1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,21 +1,21 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "inlineSourceMap": true, - "inlineSources": true, - "module": "ESNext", - "target": "es6", - "allowJs": true, - "noImplicitAny": true, - "moduleResolution": "node", - "importHelpers": true, - "lib": [ - "dom", - "es2020", - "scripthost" - ] - }, - "include": [ - "**/*.ts" - ] -} +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "es6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "lib": [ + "dom", + "es2020", + "scripthost" + ] + }, + "include": [ + "**/*.ts" + ] +}