import { App, Editor, EditorPosition, request } from 'obsidian'; import * as querystring from 'querystring'; import * as leaflet from 'leaflet'; import { PluginSettings, UrlParsingRule } from 'src/settings'; import * as utils from 'src/utils'; export type ParsedLocation = { location: leaflet.LatLng; index: number; matchLength: number; ruleName: string; placeName?: string; }; /** A class to convert a string (usually a URL) into geolocation format */ export class UrlConvertor { private settings: PluginSettings; constructor(app: App, settings: PluginSettings) { this.settings = settings; } /** * Parse the current editor line using the user defined URL parsers. * Returns leaflet.LatLng on success and null on failure. * @param editor The Obsidian Editor instance to use */ hasMatchInLine(editor: Editor): boolean { const cursor = editor.getCursor(); const result = this.parseLocationFromUrl(editor.getLine(cursor.line)); return result != null; } /** * Get geolocation from an encoded string (a URL, a lat,lng string or a URL to parse). * Will try each url parsing rule until one succeeds. * The returned value can either be a parsed & ready geolocation, or it can be a promise that still needs * to be resolved in the background (in the case of parsing a URL). * To just check if the line contains a string that can be parsed, the result can be compared to null, * but to use the value, you must await in case it's a Promise. * @param line The string to decode */ parseLocationFromUrl( line: string ): ParsedLocation | Promise<ParsedLocation> { for (const rule of this.settings.urlParsingRules) { const regexp = RegExp(rule.regExp, 'g'); const results = line.matchAll(regexp); for (let result of results) { try { if (rule.ruleType === 'fetch') { const url = result[1]; if (!url || url.length <= 0) continue; return this.parseGeolocationWithFetch( url, rule, result.index, result[0].length ); } else { return { location: rule.ruleType === 'latLng' ? new leaflet.LatLng( parseFloat(result[1]), parseFloat(result[2]) ) : new leaflet.LatLng( parseFloat(result[2]), parseFloat(result[1]) ), index: result.index, matchLength: result[0].length, ruleName: rule.name, }; } } catch (e) {} } } return null; } async parseGeolocationWithFetch( url: string, rule: UrlParsingRule, userTextMatchIndex: number, userTextMatchLength: number ): Promise<ParsedLocation> { const urlContent = await request({ url: url }); if (this.settings.debug) console.log('Fetch result for URL', url, ':', urlContent); const contentMatch = urlContent.match(rule.contentParsingRegExp); if (!contentMatch) return null; let geolocation: leaflet.LatLng = null; // TODO: Experimental, possibly unfinished code! if (rule.contentType === 'latLng' && contentMatch.length > 2) geolocation = new leaflet.LatLng( parseFloat(contentMatch[1]), parseFloat(contentMatch[2]) ); else if (rule.contentType === 'lngLat' && contentMatch.length > 2) geolocation = new leaflet.LatLng( parseFloat(contentMatch[2]), parseFloat(contentMatch[1]) ); else if (rule.contentType === 'googlePlace') { const placeName = contentMatch[1]; if (this.settings.debug) console.log('Google Place search:', placeName); // TODO work in progress // const places = await googlePlacesSearch(placeName, this.settings); // if (places && places.length > 0) geolocation = places[0].location; } if (geolocation) return { location: geolocation, index: userTextMatchIndex, matchLength: userTextMatchLength, ruleName: rule.name, }; } async getGeolocationFromGoogleLink( url: string, settings: PluginSettings ): Promise<leaflet.LatLng> { const content = await request({ url: url }); if (this.settings.debug) console.log('Google link: searching url', url); const placeNameMatch = content.match( /<meta content="([^\"]*)" itemprop="name">/ ); if (placeNameMatch) { const placeName = placeNameMatch[1]; if (this.settings.debug) console.log('Google link: found place name = ', placeName); const googleApiKey = settings.geocodingApiKey; const params = { query: placeName, key: googleApiKey, }; const googleUrl = 'https://maps.googleapis.com/maps/api/place/textsearch/json?' + querystring.stringify(params); const googleContent = await request({ url: googleUrl }); const jsonContent = JSON.parse(googleContent) as any; if ( jsonContent && 'results' in jsonContent && jsonContent?.results.length > 0 ) { const location = jsonContent.results[0].location; if (location && location.lat && location.lng) return new leaflet.LatLng(location.lat, location.lng); } } return null; } /** * Insert a geo link into the editor at the cursor position * @param location The geolocation to convert to text and insert * @param editor The Obsidian Editor instance * @param replaceStart The EditorPosition to start the replacement at. If null will replace any text selected * @param replaceLength The EditorPosition to stop the replacement at. If null will replace any text selected */ 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', ''); } /** * Replace the text at the cursor location with a geo link * @param editor The Obsidian Editor instance */ async convertUrlAtCursorToGeolocation(editor: Editor) { const cursor = editor.getCursor(); const result = this.parseLocationFromUrl(editor.getLine(cursor.line)); let geolocation: ParsedLocation; if (result instanceof Promise) geolocation = await result; else geolocation = result; if (geolocation) this.insertLocationToEditor( geolocation.location, editor, { line: cursor.line, ch: geolocation.index }, geolocation.matchLength ); } }