;
+ /** The GeoJSON leaflet object */
+ geoLayer?: leaflet.GeoJSON
+
+ constructor(file: TFile, geoJSON: GeoJsonObject) {
+ super(file);
+ this.geoJSON = geoJSON
+ this.id = this.generateId();
+ }
+
+ initGeoLayer(map: MapView) {
+ const geoJSONLayer = this;
+ try {
+ this.geoLayer = new leaflet.GeoJSON(
+ this.geoJSON,
+ {
+ onEachFeature(_, layer: leaflet.Layer) {
+ if (layer instanceof leaflet.Marker){
+ if (!geoJSONLayer.icon){
+ geoJSONLayer.icon = getIconForMarker(geoJSONLayer, map.settings, map.app);
+ }
+ layer.setIcon(geoJSONLayer.icon)
+ }
+
+ // when clicked, open the marker in an editor
+ layer.on('click', (event: leaflet.LeafletMouseEvent) => {
+ map.goToMarker(geoJSONLayer, event.originalEvent.ctrlKey, true);
+ });
+ // when hovered
+ layer.on('mouseover', (event: leaflet.LeafletMouseEvent) => {
+ let content = `${geoJSONLayer.file.name}
`;
+ if (geoJSONLayer.extraName)
+ content += ``;
+ if (geoJSONLayer.snippet)
+ content += `${geoJSONLayer.snippet}
`;
+ layer.bindPopup(content, {closeButton: true, autoPan: false}).openPopup();
+ });
+ // when stop hovering
+ layer.on(
+ 'mouseout',
+ (event: leaflet.LeafletMouseEvent) => {
+ layer.closePopup();
+ }
+ );
+ }
+ }
+ );
+ } catch (e) {
+ console.log("geoJSON is not valid geoJSON", this.geoJSON)
+ throw e;
+ }
+ }
+
+ generateId(): MarkerId {
+ return this.file.name + JSON.stringify(this.geoJSON);
+ }
+
+ isSame(other: BaseGeoLayer): boolean {
+ return other instanceof GeoJSONLayer &&
+ other.geoJSON == this.geoJSON;
+ }
+
+ getBounds(): leaflet.LatLng[] {
+ let bounds = this.geoLayer.getBounds()
+ return [bounds.getNorthEast(), bounds.getSouthWest()];
+ }
+}
+
+
+/** A class to hold all the data for a map pin */
+export class FileMarker extends BaseGeoLayer {
+ geoLayer?: leaflet.Marker;
+
+ /** The coordinate for the geographic layer */
+ location: leaflet.LatLng;
+ /** The image icon to display */
+ icon?: leaflet.Icon;
+
+ /**
+ * Construct a new map pin object
+ * @param file
+ * @param location
+ */
+ constructor(file: TFile, location: leaflet.LatLng) {
+ super(file)
this.location = location;
this.id = this.generateId();
}
- isSame(other: FileMarker) {
- return this.file.name === other.file.name &&
+ /**
+ * Is the other data equal to this
+ * @param other A BaseGeoLayer to compare to
+ */
+ isSame(other: BaseGeoLayer): boolean {
+ return other instanceof FileMarker &&
+ this.file.name === other.file.name &&
this.location.toString() === other.location.toString() &&
this.fileLocation === other.fileLocation &&
this.extraName === other.extraName &&
@@ -48,36 +195,113 @@ export class FileMarker {
generateId() : MarkerId {
return this.file.name + this.location.lat.toString() + this.location.lng.toString();
}
+
+ /** Create a leaflet layer and add it to the map */
+ initGeoLayer(map: MapView): void {
+ this.icon = getIconForMarker(this, map.settings, map.app);
+ // create the leaflet marker instance
+ this.geoLayer = leaflet.marker(this.location, { icon: this.icon || new leaflet.Icon.Default() });
+ // when clicked, open the marker in an editor
+ this.geoLayer.on('click', (event: leaflet.LeafletMouseEvent) => {
+ map.goToMarker(this, event.originalEvent.ctrlKey, true);
+ });
+ // when hovered
+ this.geoLayer.on('mouseover', (event: leaflet.LeafletMouseEvent) => {
+ let content = `${this.file.name}
`;
+ if (this.extraName)
+ content += ``;
+ if (this.snippet)
+ content += `${this.snippet}
`;
+ this.geoLayer.bindPopup(content, {closeButton: true, autoPan: false}).openPopup();
+ });
+ // when stop hovering
+ this.geoLayer.on(
+ 'mouseout',
+ (event: leaflet.LeafletMouseEvent) => {
+ this.geoLayer.closePopup();
+ }
+ );
+ // run when the layer is added to the map
+ this.geoLayer.on(
+ 'add',
+ (event: leaflet.LeafletEvent) => {
+ this.geoLayer.getElement().addEventListener(
+ 'contextmenu',
+ (ev: MouseEvent) => {
+ // on context menu creation
+ let mapPopup = new Menu(map.app);
+ mapPopup.setNoIcon();
+ mapPopup.addItem((item: MenuItem) => {
+ item.setTitle('Open note');
+ item.onClick(async ev => { map.goToMarker(this, ev.ctrlKey, true); });
+ });
+ mapPopup.addItem((item: MenuItem) => {
+ item.setTitle('Open geolocation in default app');
+ item.onClick(ev => {
+ open(`geo:${this.location.lat},${this.location.lng}`);
+ });
+ });
+ utils.populateOpenInItems(mapPopup, this.location, map.settings);
+ mapPopup.showAtPosition(ev);
+ ev.stopPropagation();
+ }
+ )
+ }
+ );
+ }
+
+ getBounds(): leaflet.LatLng[] {
+ return [this.location];
+ }
}
-export type MarkersMap = Map;
+export type MarkersMap = Map;
-export async function buildAndAppendFileMarkers(mapToAppendTo: FileMarker[], file: TFile, settings: PluginSettings, app: App, skipMetadata?: boolean) {
+/**
+ * Create file markers for every coordinate in the front matter and file body
+ * @param mapToAppendTo The list of file markers to append to
+ * @param file The file descriptor to parse
+ * @param settings The plugin settings
+ * @param app The obsidian app instance
+ * @param skipMetadata If true will not find markers in the front matter
+ */
+export async function buildAndAppendGeoLayers(mapToAppendTo: BaseGeoLayer[], file: TFile, settings: PluginSettings, app: App, skipMetadata?: boolean) {
const fileCache = app.metadataCache.getFileCache(file);
const frontMatter = fileCache?.frontmatter;
if (frontMatter) {
if (!skipMetadata) {
- const location = getFrontMatterLocation(file, app);
+ const location = getFrontMatterCoordinate(file, app);
if (location) {
verifyLocation(location);
let leafletMarker = new FileMarker(file, location);
- leafletMarker.icon = getIconForMarker(leafletMarker, settings, app);
mapToAppendTo.push(leafletMarker);
}
+ let geoJSON = getFrontMatterGeoJSON(file, app);
+ if (geoJSON) {
+ mapToAppendTo.push(new GeoJSONLayer(file, geoJSON));
+ }
}
if ('locations' in frontMatter) {
- const markersFromFile = await getMarkersFromFileContent(file, settings, app);
- mapToAppendTo.push(...markersFromFile);
+ const inlineCoordiantes = await getInlineFileMarkers(file, settings, app);
+ mapToAppendTo.push(...inlineCoordiantes);
+ const inlineGeoJSON = await getInlineGeoJSON(file, settings, app);
+ mapToAppendTo.push(...inlineGeoJSON);
}
}
}
-export async function buildMarkers(files: TFile[], settings: PluginSettings, app: App): Promise {
+/**
+ * Create file marker instances for all the files in the vault
+ * @param files
+ * @param settings
+ * @param app
+ */
+export async function buildMarkers(files: TFile[], settings: PluginSettings, app: App): Promise {
if (settings.debug)
console.time('buildMarkers');
- let markers: FileMarker[] = [];
+ let markers: BaseGeoLayer[] = [];
for (const file of files) {
- await buildAndAppendFileMarkers(markers, file, settings, app);
+ await buildAndAppendGeoLayers(markers, file, settings, app);
}
if (settings.debug)
console.timeEnd('buildMarkers');
@@ -89,7 +313,13 @@ function checkTagPatternMatch(tagPattern: string, tags: string[]) {
return match && match.length > 0;
}
-function getIconForMarker(marker: FileMarker, settings: PluginSettings, app: App) : leaflet.Icon {
+/**
+ * Create a leaflet icon for the marker
+ * @param marker The file marker to create the icon for
+ * @param settings The plugin settings
+ * @param app The obsidian app instance
+ */
+function getIconForMarker(marker: BaseGeoLayer, settings: PluginSettings, app: App) : leaflet.Icon {
const fileCache = app.metadataCache.getFileCache(marker.file);
// Combine the file tags with the marker-specific tags
const tags = getAllTags(fileCache).concat(marker.tags);
@@ -122,6 +352,11 @@ export function getIconFromOptions(iconSpec: leaflet.BaseIconOptions) : leaflet.
}
}
+/**
+ * Make sure that the coordinates are valid world coordinates
+ * -90 <= longitude <= 90 and -180 <= latitude <= 180
+ * @param location
+ */
export function verifyLocation(location: leaflet.LatLng) {
if (location.lng < consts.LNG_LIMITS[0] || location.lng > consts.LNG_LIMITS[1])
throw Error(`Lng ${location.lng} is outside the allowed limits`);
@@ -129,42 +364,94 @@ export function verifyLocation(location: leaflet.LatLng) {
throw Error(`Lat ${location.lat} is outside the allowed limits`);
}
-export function matchInlineLocation(content: string): RegExpMatchArray[] {
+/**
+ * Find all inline coordinates in a string
+ * @param content The file contents to find the coordinates in
+ */
+export function matchInlineCoordinates(content: string): RegExpMatchArray[] {
// Old syntax of ` `location: ... ` `. This syntax doesn't support a name so we leave an empty capture group
- const locationRegex1 = /\`()location:\s*\[?([0-9.\-]+)\s*,\s*([0-9.\-]+)\]?\`/g;
+ const legacyLocationRegex = /`location:\s*\[?(?[+-]?([0-9]*[.])?[0-9]+)\s*,\s*(?[+-]?([0-9]*[.])?[0-9]+)]?`/g
// New syntax of `[name](geo:...)` and an optional tags as `tag:tagName` separated by whitespaces
- const locationRegex2 = /\[(.*?)\]\(geo:([0-9.\-]+),([0-9.\-]+)\)[ \t]*((?:tag:[\w\/\-]+[\s\.]+)*)/g;
- const matches1 = content.matchAll(locationRegex1);
- const matches2 = content.matchAll(locationRegex2);
- return Array.from(matches1).concat(Array.from(matches2));
+ const geoURLlocationRegex = /\[(?.*?)]\(geo:(?[+-]?([0-9]*[.])?[0-9]+),(?[+-]?([0-9]*[.])?[0-9]+)\)[ \t]*(?(tag:[\w\/\-]+[\s.]+)*)/g;
+ const legacyMatches = content.matchAll(legacyLocationRegex);
+ const geoURLMatches = content.matchAll(geoURLlocationRegex);
+ return Array.from(legacyMatches).concat(Array.from(geoURLMatches));
}
-async function getMarkersFromFileContent(file: TFile, settings: PluginSettings, app: App): Promise {
+/**
+ * Find all inline geoJSON in a string
+ * @param content The file contents to find the coordinates in
+ */
+export function matchInlineGeoJSON(content: string): RegExpMatchArray[] {
+ // New syntax of `[name](geo:...)` and an optional tags as `tag:tagName` separated by whitespaces
+ const geoJSONlocationRegex = /```geoJSON\n(?.*?)```/gs;
+ const geoJSONMatches = content.matchAll(geoJSONlocationRegex);
+ return Array.from(geoJSONMatches);
+}
+
+/**
+ * Get markers from within the file body
+ * @param file The file descriptor to load
+ * @param settings The plugin settings
+ * @param app The obsidian app instance
+ */
+async function getInlineFileMarkers(file: TFile, settings: PluginSettings, app: App): Promise {
let markers: FileMarker[] = [];
const content = await app.vault.read(file);
- const matches = matchInlineLocation(content);
+ const matches = matchInlineCoordinates(content);
for (const match of matches) {
try {
- const location = new leaflet.LatLng(parseFloat(match[2]), parseFloat(match[3]));
+ const location = new leaflet.LatLng(parseFloat(match.groups.lat), parseFloat(match.groups.long));
verifyLocation(location);
const marker = new FileMarker(file, location);
- if (match[1] && match[1].length > 0)
- marker.extraName = match[1];
- if (match[4]) {
+ if (match.groups.name && match.groups.name.length > 0)
+ marker.extraName = match.groups.name;
+ if (match.groups.tags) {
// Parse the list of tags
- const tagRegex = /tag:([\w\/\-]+)/g;
- const tags = match[4].matchAll(tagRegex);
+ const tagRegex = /tag:(?[\w\/\-]+)/g;
+ const tags = match.groups.tags.matchAll(tagRegex);
for (const tag of tags)
- if (tag[1])
- marker.tags.push('#' + tag[1]);
+ if (tag.groups.tag)
+ marker.tags.push('#' + tag.groups.tag);
}
marker.fileLocation = match.index;
- marker.icon = getIconForMarker(marker, settings, app);
marker.snippet = await makeTextSnippet(file, content, marker.fileLocation, settings);
markers.push(marker);
}
catch (e) {
- console.log(`Error converting location in file ${file.name}: could not parse ${match[1]} or ${match[2]}`, e);
+ console.log(`Error converting location in file ${file.name}: could not parse ${match[0]}`, e);
+ }
+ }
+ return markers;
+}
+
+/**
+ * Get geoJSON from within the file body
+ * @param file The file descriptor to load
+ * @param settings The plugin settings
+ * @param app The obsidian app instance
+ */
+async function getInlineGeoJSON(file: TFile, settings: PluginSettings, app: App): Promise {
+ let markers: GeoJSONLayer[] = [];
+ const content = await app.vault.read(file);
+ const matches = matchInlineGeoJSON(content);
+ for (const match of matches) {
+ try {
+ const geoJSONData = JSON.parse(match.groups.geoJSON);
+ const geoJSON = new GeoJSONLayer(file, geoJSONData);
+ if (geoJSONData instanceof Object && geoJSONData.tags && geoJSONData.tags instanceof Array) {
+ for (const tag of geoJSONData.tags) {
+ if (tag instanceof String) {
+ geoJSON.tags.push('#' + tag);
+ }
+ }
+ }
+ geoJSON.fileLocation = match.index;
+ geoJSON.snippet = await makeTextSnippet(file, content, geoJSON.fileLocation, settings);
+ markers.push(geoJSON);
+ }
+ catch (e) {
+ console.log(`Error converting inline geoJSON from ${file.name}: could not parse ${match.groups.geoJSON}`, e);
}
}
return markers;
@@ -182,7 +469,7 @@ async function makeTextSnippet(file: TFile, fileContent: string, fileLocation: n
const prevLine = fileContent.lastIndexOf('\n', snippetStart - 1);
const line = fileContent.substring(snippetStart, prevLine);
// If the new line above contains another location, don't include it and stop
- if (matchInlineLocation(line).length > 0)
+ if (matchInlineCoordinates(line).length > 0)
break;
snippetStart = prevLine;
linesAbove -= 1;
@@ -196,7 +483,7 @@ async function makeTextSnippet(file: TFile, fileContent: string, fileLocation: n
const nextLine = fileContent.indexOf('\n', snippetEnd + 1);
const line = fileContent.substring(snippetEnd, nextLine > -1 ? nextLine : fileContent.length);
// If the new line below contains another location, don't include it and stop
- if (matchInlineLocation(line).length > 0)
+ if (matchInlineCoordinates(line).length > 0)
break;
snippetEnd = nextLine;
linesBelow -= 1;
@@ -210,7 +497,12 @@ async function makeTextSnippet(file: TFile, fileContent: string, fileLocation: n
return snippet;
}
-export function getFrontMatterLocation(file: TFile, app: App) : leaflet.LatLng {
+/**
+ * Get the coordinates stored in the front matter of a file
+ * @param file The file to load the front matter from
+ * @param app The app to load the file from
+ */
+export function getFrontMatterCoordinate(file: TFile, app: App) : leaflet.LatLng {
const fileCache = app.metadataCache.getFileCache(file);
const frontMatter = fileCache?.frontmatter;
if (frontMatter && frontMatter?.location) {
@@ -231,3 +523,17 @@ export function getFrontMatterLocation(file: TFile, app: App) : leaflet.LatLng {
}
return null;
}
+
+/**
+ * Get the geoJSON stored in the front matter of a file
+ * @param file The file to load the front matter from
+ * @param app The app to load the file from
+ */
+export function getFrontMatterGeoJSON(file: TFile, app: App) : GeoJsonObject {
+ const fileCache = app.metadataCache.getFileCache(file);
+ const frontMatter = fileCache?.frontmatter;
+ if (frontMatter && frontMatter?.geoJSON) {
+ return frontMatter.geoJSON;
+ }
+ return null;
+}
\ No newline at end of file
diff --git a/src/newNoteDialog.ts b/src/newNoteDialog.ts
index eeb14e8..b69a80f 100644
--- a/src/newNoteDialog.ts
+++ b/src/newNoteDialog.ts
@@ -3,7 +3,7 @@ import * as leaflet from 'leaflet';
import { PluginSettings } from 'src/settings';
import { LocationSuggest } from 'src/geosearch';
-import { UrlConvertor } from 'src/urlConvertor';
+import { CoordinateParser } from 'src/coordinateParser';
import { MapView } from 'src/mapView';
import * as utils from 'src/utils';
import * as consts from 'src/consts';
@@ -17,7 +17,7 @@ class SuggestInfo {
export class NewNoteDialog extends SuggestModal {
private settings: PluginSettings;
private suggestor: LocationSuggest;
- private urlConvertor: UrlConvertor;
+ private coordinateParser: CoordinateParser;
private lastSearchTime = 0;
private delayInMs = 250;
private lastSearch = '';
@@ -30,7 +30,7 @@ export class NewNoteDialog extends SuggestModal {
super(app);
this.settings = settings;
this.suggestor = new LocationSuggest(this.app, this.settings);
- this.urlConvertor = new UrlConvertor(this.app, this.settings);
+ this.coordinateParser = new CoordinateParser(this.app, this.settings);
this.dialogAction = dialogAction;
this.editor = editor;
@@ -81,9 +81,9 @@ export class NewNoteDialog extends SuggestModal {
}
}
- async addToNote(location: leaflet.LatLng, ev: MouseEvent | KeyboardEvent, query: string) {
- const locationString = `[${location.lat},${location.lng}]`;
- utils.verifyOrAddFrontMatter(this.editor, 'location', locationString);
+ async addToNote(coordinate: leaflet.LatLng, ev: MouseEvent | KeyboardEvent, query: string) {
+ const coordianteArray = [coordinate.lat,coordinate.lng];
+ utils.frontMatterSetDefault(this.editor, 'location', coordianteArray);
}
async getSearchResultsWithDelay(query: string) {
@@ -112,7 +112,7 @@ export class NewNoteDialog extends SuggestModal {
}
parseLocationAsUrl(query: string): SuggestInfo {
- const result = this.urlConvertor.parseLocationFromUrl(query);
+ const result = this.coordinateParser.parseString(query);
if (result)
return {
name: `Parsed from ${result.ruleName}: ${result.location.lat}, ${result.location.lng}`,
diff --git a/src/urlConvertor.ts b/src/urlConvertor.ts
deleted file mode 100644
index a2f975f..0000000
--- a/src/urlConvertor.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { App, Editor, EditorPosition } from 'obsidian';
-
-import * as leaflet from 'leaflet';
-import { PluginSettings } from 'src/settings';
-import * as utils from 'src/utils';
-
-export class UrlConvertor {
- private settings: PluginSettings;
-
- constructor(app: App, settings: PluginSettings) {
- this.settings = settings;
- }
-
- findMatchInLine(editor: Editor) {
- const cursor = editor.getCursor();
- const result = this.parseLocationFromUrl(editor.getLine(cursor.line));
- return result?.location;
- }
-
- parseLocationFromUrl(line: string) {
- for (const rule of this.settings.urlParsingRules) {
- const regexp = RegExp(rule.regExp, 'g');
- const results = line.matchAll(regexp);
- for (let result of results) {
- try {
- return {
- location: new leaflet.LatLng(parseFloat(result[1]), parseFloat(result[2])),
- index: result.index,
- matchLength: result[0].length,
- ruleName: rule.name
- };
- }
- catch (e) { }
- }
- }
- return null;
- }
-
- insertLocationToEditor(location: leaflet.LatLng, editor: Editor, replaceStart?: EditorPosition, replaceLength?: number) {
- const locationString = `[](geo:${location.lat},${location.lng})`;
- const cursor = editor.getCursor();
- if (replaceStart && replaceLength) {
- editor.replaceRange(locationString, replaceStart, {line: replaceStart.line, ch: replaceStart.ch + replaceLength});
- }
- else
- editor.replaceSelection(locationString);
- // We want to put the cursor right after the beginning of the newly-inserted link
- const newCursorPos = replaceStart ? replaceStart.ch + 1 : cursor.ch + 1;
- editor.setCursor({line: cursor.line, ch: newCursorPos});
- utils.verifyOrAddFrontMatter(editor, 'locations', '');
- }
-
- convertUrlAtCursorToGeolocation(editor: Editor) {
- const cursor = editor.getCursor();
- const result = this.parseLocationFromUrl(editor.getLine(cursor.line));
- if (result)
- this.insertLocationToEditor(result.location, editor, {line: cursor.line, ch: result.index}, result.matchLength);
- }
-}
diff --git a/src/utils.ts b/src/utils.ts
index 8c84d2c..e37b2b6 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,4 +1,4 @@
-import { WorkspaceLeaf, MarkdownView, Editor, App, TFile, Menu, MenuItem } from 'obsidian';
+import {WorkspaceLeaf, MarkdownView, Editor, App, TFile, Menu, MenuItem, stringifyYaml, parseYaml, Notice} from 'obsidian';
import * as moment_ from 'moment';
import * as leaflet from 'leaflet';
@@ -22,30 +22,50 @@ type NewNoteType = 'singleLocation' | 'multiLocation';
const CURSOR = '$CURSOR$';
-export async function newNote(app: App, newNoteType: NewNoteType, directory: string, fileName: string,
- location: string, templatePath?: string): Promise
+/**
+ * Create a new markdown note and populate with the location
+ * @param app The obsidian app instance
+ * @param newNoteType The location format to encode as
+ * @param directory The directory path to put the file in
+ * @param fileName The name of the file
+ * @param coordinate The coordinate
+ * @param templatePath Optional path to a file to populate the file with
+ */
+export async function newNote(
+ app: App,
+ newNoteType: NewNoteType,
+ directory: string,
+ fileName: string,
+ coordinate: string,
+ templatePath?: string
+): Promise
{
// `$CURSOR$` is used to set the cursor
let content = newNoteType === 'singleLocation' ?
- `---\nlocation: [${location}]\n---\n\n${CURSOR}` :
- `---\nlocations:\n---\n\n\[${CURSOR}](geo:${location})\n`;
- let templateContent = '';
+ `---\nlocation: [${coordinate}]\n---\n\n${CURSOR}` :
+ `---\nlocations:\n---\n\n\[${CURSOR}](geo:${coordinate})\n`;
if (templatePath)
- templateContent = await app.vault.adapter.read(templatePath);
+ content = content + await app.vault.adapter.read(templatePath);
let fullName = path.join(directory || '', fileName);
if (await app.vault.adapter.exists(fullName + '.md'))
fullName += Math.random() * 1000;
try {
- return app.vault.create(fullName + '.md', content + templateContent);
+ return app.vault.create(fullName + '.md', content);
}
catch (e) {
throw Error(`Cannot create file named ${fullName}: ${e}`);
}
}
-export async function goToEditorLocation(editor: Editor, fileLocation: number, highlight: boolean) {
- if (fileLocation) {
- let pos = editor.offsetToPos(fileLocation);
+/**
+ * Go to a character index in the note
+ * @param editor The obsidian Editor instance
+ * @param characterIndex The character index in the file to go to
+ * @param highlight If true will select the whole line
+ */
+export async function goToEditorLocation(editor: Editor, characterIndex: number, highlight: boolean) {
+ if (characterIndex) {
+ let pos = editor.offsetToPos(characterIndex);
if (highlight) {
const lineContent = editor.getLine(pos.line);
editor.setSelection({ch: 0, line: pos.line}, {ch: lineContent.length, line: pos.line});
@@ -66,36 +86,97 @@ export async function handleNewNoteCursorMarker(editor: Editor) {
}
}
-// Creates or modifies a front matter that has the field `fieldName: fieldValue`.
-// Returns true if a change to the note was made.
-export function verifyOrAddFrontMatter(editor: Editor, fieldName: string, fieldValue: string): boolean {
+/**
+ * A utility for reading, modifying and writing back front matter.
+ * Load front matter from the current file and pass it to edit_callback.
+ * The returned value from the callback replaces the original front matter
+ * Return true if the yaml is changed
+ * @param editor The obsidian editor instance
+ * @param edit_callback The callback to modify the parsed object. Must return the modified object or null if no changes were made.
+ */
+export function updateFrontMatter(
+ editor: Editor,
+ edit_callback: (frontMatter: any) => any | null
+){
const content = editor.getValue();
- const frontMatterRegex = /^---(.*)^---/ms;
- const frontMatter = content.match(frontMatterRegex);
- const existingFieldRegex = new RegExp(`^---.*${fieldName}:.*^---`, 'ms');
- const existingField = content.match(existingFieldRegex);
- const cursorLocation = editor.getCursor();
- // That's not the best usage of the API, and rather be converted to editor transactions or something else
- // that can preserve the cursor position better
- if (frontMatter && !existingField) {
- const replaced = `---${frontMatter[1]}${fieldName}: ${fieldValue}\n---`;
- editor.setValue(content.replace(frontMatterRegex, replaced));
- editor.setCursor({line: cursorLocation.line + 1, ch: cursorLocation.ch});
- return true;
- } else if (!frontMatter) {
- const newFrontMatter = `---\n${fieldName}: ${fieldValue}\n---\n\n`;
- editor.setValue(newFrontMatter + content);
- editor.setCursor({line: cursorLocation.line + newFrontMatter.split('\n').length - 1, ch: cursorLocation.ch});
- return true;
+ const frontMatterRegex = /^---(.*?)\n---/s;
+ const frontMatterMatch = content.match(frontMatterRegex);
+ let frontMatter: any
+ if (frontMatterMatch){
+ // found valid front matter
+ const frontMatterYaml = frontMatterMatch[1];
+ try {
+ // parse the front matter into an object for easier handling
+ frontMatter = parseYaml(frontMatterYaml);
+ } catch (error) {
+ new Notice("Could not parse front matter yaml.\n" + error);
+ return false
+ }
+ if (typeof frontMatter === "object") {
+ frontMatter = edit_callback(frontMatter);
+ if (frontMatter) {
+ const newFrontMatter = `---\n${stringifyYaml(frontMatter)}\n---`;
+ const cursorLocation = editor.getCursor();
+ editor.setValue(content.replace(frontMatterRegex, newFrontMatter));
+ editor.setCursor({line: cursorLocation.line + 1, ch: cursorLocation.ch});
+ return true;
+ }
+ } else {
+ new Notice("Expected yaml front matter root to be an object/dictionary.");
+ }
+ } else {
+ // did not find valid front matter
+ frontMatter = {};
+ frontMatter = edit_callback(frontMatter);
+ if (frontMatter) {
+ const newFrontMatter = `---\n${stringifyYaml(frontMatter)}\n---\n\n`;
+ const cursorLocation = editor.getCursor();
+ editor.setValue(newFrontMatter + content);
+ editor.setCursor({line: cursorLocation.line + newFrontMatter.split('\n').length - 1, ch: cursorLocation.ch});
+ return true;
+ }
}
return false;
}
-export function populateOpenInItems(menu: Menu, location: leaflet.LatLng, settings: settings.PluginSettings) {
+/**
+ * Create or modify a front matter that has the field `fieldName: fieldValue`.
+ * Returns true if a change to the note was made.
+ * @param editor The obsidian Editor instance
+ * @param key_name The key to set in the yaml front matter
+ * @param default_value The default value to populate with if not defined. Defaults to null.
+ */
+export function frontMatterSetDefault(editor: Editor, key_name: string, default_value?: any): boolean {
+ return updateFrontMatter(
+ editor,
+ (frontMatter) => {
+ if (frontMatter.hasOwnProperty(key_name)) {
+ // if the front matter already has this key
+ if (frontMatter[key_name] === null && default_value !== null) {
+ // if the key exists but the value is null
+ frontMatter[key_name] = default_value
+ return frontMatter
+ }
+ return null;
+ } else {
+ frontMatter[key_name] = default_value
+ return frontMatter
+ }
+ }
+ )
+}
+
+/**
+ * Populate a context menu from the user configurable urls
+ * @param menu The menu to attach
+ * @param coordinate The coordinate to use in the menu item
+ * @param settings Plugin settings
+ */
+export function populateOpenInItems(menu: Menu, coordinate: leaflet.LatLng, settings: settings.PluginSettings) {
for (let setting of settings.openIn) {
if (!setting.name || !setting.urlPattern)
continue;
- const fullUrl = setting.urlPattern.replace('{x}', location.lat.toString()).replace('{y}', location.lng.toString());
+ const fullUrl = setting.urlPattern.replace('{x}', coordinate.lat.toString()).replace('{y}', coordinate.lng.toString());
menu.addItem((item: MenuItem) => {
item.setTitle(`Open in ${setting.name}`);
item.onClick(_ev => {