Reformatted

This commit is contained in:
gentlegiantJGC 2022-04-25 09:11:46 +01:00
parent 2054929fad
commit 897fed7a7c
19 changed files with 3603 additions and 2821 deletions

View file

@ -1,16 +1,16 @@
name: Stylecheck
on:
push:
branches: [master, main]
pull_request:
push:
branches: [master, main]
pull_request:
jobs:
stylecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: |
npm install
- run: |
npm run stylecheck
stylecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: |
npm install
- run: |
npm run stylecheck

726
README.md
View file

@ -1,363 +1,363 @@
# Obsidian.md Map View
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/esm7)
## 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.
It also provides a wide range of tools to add geolocations to your notes, including address searches and parsing of geolocations from external sources such as Google Maps.
![](img/sample.png)
![](img/intro.gif)
The plugin's 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.
## Limitations
- Experience in mobile is not as good as it should be. Most notably there's no GPS location support due to permission limitations of the Obsidian app. Please help us ask the Obsidian developers to get these permissions added!
## Support the Development
If you want to support the development of this plugin, please consider to [buy me a coffee](https://www.buymeacoffee.com/esm7).
## 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 URLs** in the format of `[link-name](geo:40.68,-74.04)`, 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](geo:42.277578,-76.1598107)
... more note content ...
Point 2: [New Haven](geo: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.
For many cases inline locations are superior because `geo:` is a [native URL scheme](https://en.wikipedia.org/wiki/Geo_URI_scheme), so if you click it in Obsidian (including mobile), your default maps app (or an app selector for a location) will be triggered.
The front matter method, however, is currently better if you want interoperability with plugins that use it, or if you want to store lots of filterable meta-data on a location.
Inline locations also support **inline tags** in the format of `tag:dogs`. For example:
```
Point 1: [Hudson River](geo:42.277578,-76.1598107) tag:dogs
```
This will add the tag `#dogs` specifically to that point, regardless of the note's own tags.
This is useful for notes that contain tags of different types (e.g. a trip log with various types of locations).
Note that the `tag:` format should be used **without** the `#` sign, because this sets the tag for the whole note.
Map View will add `#` for the purpose of setting marker icons, as explained below.
Also note that inline tags do not affect the files that are searched based on the entered search query, so in the example above, if you enter '#dogs' in the search box, your note would **not** appear.
Note: older versions of this plugin used the notation `location: ...` for inline locations.
This notation is still supported but the standard `geo:` one is the default and encouraged one.
Multiple inline tags can be separated with a whitespace: `[](geo:42.2,-76.15) tag:dogs tag:trip`.
Multiple inline locations can be added in the same line, and the tags that follow them will be associated to the location on the left, but the right-click editor context menu will not know to choose the location that was selected.
## Adding a Location to a Note
Map View offers many ways to add locations to notes.
### Anywhere in Obsidian
Map View adds an Obsidian command named "New geolocation note", which you can map to a hotkey and use anywhere in Obsidian.
This opens a dialog on which you can search (address or location based on your [configured geocoding provider](#changing-a-geocoding-provider)) or paste a URL using the built-in or custom [URL parsing rules](#url-parsing-rules).
![](img/new-note-popup.gif)
### In an Existing Note
There are multiple ways to add a geolocation to an existing note.
1. Create an inline geolocation link in the format of `[](geo:)`, and if you start typing inside the link name (the brackets), Map View will initiate a location search. If you confirm one of the options, it will fill-in the location's coordinates. See more on this in the ["In-Note Location Search"](#in-note-location-search--auto-complete) section below.
To make this more streamlined, Map View adds to Obsidian a command named 'Add inline geolocation link' which you can map to a keyboard shortcut.
2. Add a front matter geolocation by using the Obsidian command 'Add geolocation (front matter) to current note'. This opens the same dialog as "new geolocation note" which allows you to search for a location name or paste a [URL parsing rules](#url-parsing-rules).
3. If you have a location in some other mapping service that you wish to log, e.g. from Google Maps, you can copy the URL or "lat,lng" geolocation from that service, right-click in your note and select "Paste as Geolocation". The supported services are configurable, see [below](#url-parsing-rules) for more details.
### From the Map
The map offers several tools to create notes.
1. Use "new note here" when right-clicking the map. This will create a new note (based on the template you can change in the settings) with the location you clicked. You can create either an empty note with a front matter (single geolocation) or an empty note with an inline geolocation.
![](img/new-note.png)
Note that the map can be searched using the tool on the upper-right side.
![](img/search.png)
2. If you prefer to enter geolocations as text, use one of the "copy geolocation" options when you right-click the map. If you use "copy geolocation", just remember you need the note to start with a front matter that has an empty `locations:` line.
![](img/copy.png)
## Paste as Geolocation
Map View monitors the system clipboard, and when a URL is detected to have an encoded geolocation (e.g. a Google Maps "lat, lng" location), a "Paste as geolocation" entry is added to the editor context menu.
For example, if you right-click a location in Google Maps and click the first item in the menu (coordinates in lat,lng format), you can then paste it as a geolocation inside a note.
Alternatively, you can right-click a URL that is already present in a note and choose "Convert to geolocation".
By default Map View can parse URLs from two services: the OpenStreetMap "show address" link and a generic "lat, lng" encoding used by many URLs.
## 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.
Alternatively, if you follow an inline geolocation link by `tag:yourTagName`, this tag will be added to that geolocation in addition to the note's tags.
## Marker Icons
Map View allows you to customize notes' map marker icons based on a powerful rules system. These rules can be edited using the plugin's settings pane or edited as JSON for some even more fine-grained control.
Icons are based on [Font Awesome](https://fontawesome.com/), so to add a marker icon you'll need to find its name in the Font Awesome catalog.
Additionally, there are various marker properties (shape, color and more) that are based on [Leaflet.ExtraMarkers](https://github.com/coryasilva/Leaflet.ExtraMarkers#properties).
To change the map marker icons for your notes, go to the Map View settings and scroll to Marker Icon Rules.
A single marker is defined with a *tag pattern* and *icon details*.
The tag pattern is usually a tag name (e.g. `#dogs`), but it can also be with a wildcard (e.g. `#trips/*`).
Icon details are a few properties: icon name (taken from the Font Awesome catalog), color and shape.
![](img/marker-rules.png)
A single marker is defined in the following JSON structure:
`{"prefix": "fas", "icon": "fa-bus", "shape": "circle", "color": "red"}`
To add a marker with a bus icon, click New Icon Rule, search Font Awesome (in the link above) for 'bus', choose [this icon](https://fontawesome.com/v5.15/icons/bus?style=solid), then see that its name is `fa-bus`.
Once you enter `fa-bus` in the icon name, you should immediately see your icon in the preview.
To make this icon apply for notes with the `#travel` tag, type `#travel` in the Tag Name box.
### Tag Rules
To apply an icon to a note with geolocation data, Map View scans the complete list of rules by their order, always starting from `default`.
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:
```
{ruleName: "default", preset: true, iconDetails: {"prefix": "fas", "icon": "fa-circle", "markerColor": "blue"}},
{ruleName: "#trip", preset: false, iconDetails: {"prefix": "fas", "icon": "fa-hiking", "markerColor": "green"}},
{ruleName: "#trip-water", preset: false, iconDetails: {"prefix": "fas", "markerColor": "blue"}},
{ruleName: "#dogs", preset: false, iconDetails: {"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.
Tag rules also support wildcards, e.g. a rule in the form of `"#food*": {...}` will match notes with the tag `#food`, `#food/pizza`, `#food/vegan`, `#food-to-try` etc.
The settings also allow advanced users to manually edit the configuration tree, and there you can use more properties based on the [Leaflet.ExtraMarkers](https://github.com/coryasilva/Leaflet.ExtraMarkers#properties) properties. Manual edits update the GUI in real-time.
## In-Note Location Search & Auto-Complete
Map View adds an Obsidian command named 'Add inline geolocation link', that you can (and encouraged) to map to a keyboard shortcut, e.g. `Ctrl+L` or `Ctrl+Shift+L`.
This command inserts an empty inline location template: `[](geo:)`.
When editing an inline location in this format, whether if you added it manually or using the command, if you start entering a link name, Map View will start offering locations based on a geocoding service.
Selecting one of the suggestions will fill-in the coordinates of the chosen locations, *not* change your link name (assuming you prefer your own name rather than the formal one offered by the geocoding service), and jump the cursor to beyond the link so you can continue typing.
![](img/geosearch-suggest.gif)
If your note is not yet marked as one including locations (by a `locations:`) tag in the front matter, this is added automatically.
### Changing a Geocoding Provider
By default, Map View is configured to use OpenStreetMap as the search provider.
If you prefer to use the Google Maps search, you can configure this in the plugin settings.
The Google Geocoding API is practically free or very cheap for normal note-taking usage, but you'd need to setup a project and obtain an API key from Google.
See [here](https://developers.google.com/maps/documentation/javascript/get-api-key) for more details.
**Note:** usage of any geocoding provider is at your own risk, and it's your own responsibility to verify you are not violating the service's terms of usage.
## Map Sources
By default, Map View uses the [CartoDB Voyager Map](https://github.com/CartoDB/basemap-styles), which is free for up to 75K requests per month.
However, you can change or add map sources in the configuration with 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.
If you have multiple map sources, they can be switched from the View pane.
Additionally, you can set an optional different dark theme URL for each map source.
If a dark theme is detected, or if you specifically change the map source type to Dark (using the drop down in the View pane), you will get the Dark URL if one is configured.
## Presets
If there is a map state you would like to save and easily come back to, you can save it as a preset.
To do so, open the Presets pane in the main plugin's controls, and click 'Save as' to save the current view with a name you can easily go back to.
If you enter an already-existing name, that preset will be overwritten.
The saved preset includes the map state (zoom & pan), the filters used, and if you check the box in the "save as" dialog -- also the chosen map source.
If you do not include the map source as part of the preset, switching to the newly-saved preset will use the currently-selected map source.
Presets *do not* store the map's theme (light/dark).
The Default preset is special; you can save it using the 'Save as Default' button, and come back to it by clicking the Reset button, by choosing the Default preset from the box, or by opening a fresh Map View that has no previously saved state.
## Open In
Many context menus of Map View display a customizable Open In list, which can open a given location in external sources.
These sources can be Google Maps, OpenStreetMap, specialized mapping tools or pretty much anything you use for viewing locations.
![](img/open-in.png)
The Open In list is shown:
- When right-clicking on the map.
- When right-clicking a marker on the map.
- When right-clicking a line in a note that has a location.
- In the context menu of a note that has a front matter location.
This list can be edited through the plugin's settings menu, with a name that will be displayed in the context menus and a URL pattern. The URL pattern has two parameters -- `{x}` and `{y}` -- that will be replaced by the latitude and longitude of the clicked location.
![](img/custom-open-in.png)
Popular choices may be:
- Google Maps: `https://maps.google.com/?q={x},{y}`
- OpenStreetMap: `https://www.openstreetmap.org/#map=16/{x}/{y}` (replace `16` with your preferred zoom level)
- Waze (online dropped pin): `https://ul.waze.com/ul?ll={x}%2C{y}&navigate=yes&zoom=17` (replace `17` with your preferred zoom level)
And you can figure out many other mapping services just by inspecting the URL.
## URL Parsing Rules
As described above, Map View uses *URL parsing rules* in several places to provide the ability to parse URLs (or other strings) from external sources and convert them to standard geolocations.
1. When right-clicking a line with a recognized link, a "Convert to Geolocation" entry will be shown in the editor context menu.
2. When a recognized link is detected in the system clipboard, a "Paste as Geolocation" entry will be added in the editor context menu.
3. In the "New geolocation note" dialog, pasting a supported URL will parse the geolocation.
URL parsing rules can be configured in the plugin's configuration pane and requires familiarity with regular expressions.
The syntax expects two captures group and you can configure if they are parsed as `lat, lng` (most common) or `lng, lat`.
And if you think your added regular expressions are solid enough, please add them to the plugin using a PR so others can benefit!
![](img/url-parsing.png)
## 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...
- 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.
- A side bar with note summaries linked to the map view.
## Changelog
### 1.5.0
- Map View now saves its state to the Obsidian back/forward mechanism (unless configured not to).
- Fixed an issue with markers temporarily duplicating (until the map is refreshed) when their note is renamed.
### 1.4.0
- Replaced OpenStreetMap with CartoDB as the new default map source (https://github.com/esm7/obsidian-map-view/issues/59).
- Map View now displays a more useful error when tiles fail to load.
- Removed the "Google Maps" default URL parsing rule because apparently it was incorrect (https://github.com/esm7/obsidian-map-view/issues/57). It is now replaced by a more generic "lat,lng" rule that can also be used with Google Maps, *not* by parsing the URL but by right-clicking the map in Google Maps and choosing the first menu item that copies the coordinates.
- Hovering on a map marker now opens the Obsidian note preview, scrolled to the correct line (https://github.com/esm7/obsidian-map-view/issues/60). This is configurable in the settings and comes *in addition* to the existing note pop-up (without a snippet), because the preview does not include the note name.
- **Important note:** this replaces the "snippet" functionality of previous versions.
- Marker clusters now show a preview of their own; they show a popup with the first 4 icons in the cluster.
### 1.3.0
- Introduction of **Presets** as a way to save the state of the view including the map state, filters and optionally the chosen map source.
- As part of this, the 'default view' functionality was converted to a built-in 'Default' preset and the UI was revamped accordingly.
- As part of the above, the internal structure of the plugin had to be reorganized and many features needed to be rewired. **I put major effort to test the entire functionality of the plugin, but pretty much anything could break and I may have missed some bugs.** Please open issues if something had stopped working.
- When using `{{query}}` in file name templates, the file name is sanitized so the creation won't fail on illegal strings.
### 1.2.1
- The "new geolocation note" dialog can now also be used to add a location to an existing note, both via a new Obsidian command and a file menu action (https://github.com/esm7/obsidian-map-view/issues/46).
- Added a `{{query}}` file name template option to auto-add the search query for new location notes (https://github.com/esm7/obsidian-map-view/issues/45).
### 1.2.0
- Added a "new geolocation note" Obsidian command with an interactive search and URL parser.
- Fixed a bug of highlighting more than the intended line after clicking a map marker and opening a note.
- Fixed a bug of inline tags not recognized if immediately followed by a dot (which is a common pattern).
- Fixed an exception that could prevent settings to be applied after closing the settings dialog.
### 1.1.0
- The plugin now supports a configurable list of map sources, switchable from the View pane on the map, including optional separate URLs for light & dark themes.
- Support dark mode using a CSS hue revert, as [recommended for Leaflet](https://gist.github.com/BrendonKoz/b1df234fe3ee388b402cd8e98f7eedbd) and as done by obsidian-leaflet.
- [Support multiple inline locations per line](https://github.com/esm7/obsidian-map-view/issues/35).
- [Support multiple tags per inline location](https://github.com/esm7/obsidian-map-view/issues/32).
- Fixed an [issue](https://github.com/esm7/obsidian-map-view/issues/30) of inline tags not properly including the dash (`-`) character.
- Popups now have a close button (to make them more mobile-friendly).
### 1.0.0
- UI revisions and cleanups to make it easier for new users. **Some changes break existing notions.**
- Map View now treats "inline locations" as the default. It is first in the menus, and front matter actions are listed as "front matter".
- "Copy as coordinates" was removed, believing it's not really useful anymore. Please drop me a note if you find it important.
- Several other tweaks to make the plugin easier to use.
- At last, a settings UI for editing marker icons! Check it out under the plugin settings.
- In order to do this, the rule format in the plugin's data file had to be changed. The plugin will auto-convert your existing rules to the new format when first launched. After this conversion, *do not* manually enter rules under the old key!
- You may still edit rules manually, through the plugin's "edit marker icons as JSON" box or directly in the data file, but **only use the new key** `markerIconRules`.
- Added an inline geolocation search, which can get results from either OSM or Google (with an API key), and a command to insert a geolocation.
- With relation to the above, the search tool in the map can now use the selected geocoding service too, meaning you can use Google Maps to search for locations if you have an API key.
- New "paste as geolocation" and "convert to geolocation" right-click editor menu items, that can automatically convert URLs in the clipboard or URLs in the note to a geolocation link.
- The rules that convert URLs to geolocations are configurable in the settings.
# Obsidian.md Map View
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/esm7)
## 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.
It also provides a wide range of tools to add geolocations to your notes, including address searches and parsing of geolocations from external sources such as Google Maps.
![](img/sample.png)
![](img/intro.gif)
The plugin's 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.
## Limitations
- Experience in mobile is not as good as it should be. Most notably there's no GPS location support due to permission limitations of the Obsidian app. Please help us ask the Obsidian developers to get these permissions added!
## Support the Development
If you want to support the development of this plugin, please consider to [buy me a coffee](https://www.buymeacoffee.com/esm7).
## 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 URLs** in the format of `[link-name](geo:40.68,-74.04)`, 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](geo:42.277578,-76.1598107)
... more note content ...
Point 2: [New Haven](geo: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.
For many cases inline locations are superior because `geo:` is a [native URL scheme](https://en.wikipedia.org/wiki/Geo_URI_scheme), so if you click it in Obsidian (including mobile), your default maps app (or an app selector for a location) will be triggered.
The front matter method, however, is currently better if you want interoperability with plugins that use it, or if you want to store lots of filterable meta-data on a location.
Inline locations also support **inline tags** in the format of `tag:dogs`. For example:
```
Point 1: [Hudson River](geo:42.277578,-76.1598107) tag:dogs
```
This will add the tag `#dogs` specifically to that point, regardless of the note's own tags.
This is useful for notes that contain tags of different types (e.g. a trip log with various types of locations).
Note that the `tag:` format should be used **without** the `#` sign, because this sets the tag for the whole note.
Map View will add `#` for the purpose of setting marker icons, as explained below.
Also note that inline tags do not affect the files that are searched based on the entered search query, so in the example above, if you enter '#dogs' in the search box, your note would **not** appear.
Note: older versions of this plugin used the notation `location: ...` for inline locations.
This notation is still supported but the standard `geo:` one is the default and encouraged one.
Multiple inline tags can be separated with a whitespace: `[](geo:42.2,-76.15) tag:dogs tag:trip`.
Multiple inline locations can be added in the same line, and the tags that follow them will be associated to the location on the left, but the right-click editor context menu will not know to choose the location that was selected.
## Adding a Location to a Note
Map View offers many ways to add locations to notes.
### Anywhere in Obsidian
Map View adds an Obsidian command named "New geolocation note", which you can map to a hotkey and use anywhere in Obsidian.
This opens a dialog on which you can search (address or location based on your [configured geocoding provider](#changing-a-geocoding-provider)) or paste a URL using the built-in or custom [URL parsing rules](#url-parsing-rules).
![](img/new-note-popup.gif)
### In an Existing Note
There are multiple ways to add a geolocation to an existing note.
1. Create an inline geolocation link in the format of `[](geo:)`, and if you start typing inside the link name (the brackets), Map View will initiate a location search. If you confirm one of the options, it will fill-in the location's coordinates. See more on this in the ["In-Note Location Search"](#in-note-location-search--auto-complete) section below.
To make this more streamlined, Map View adds to Obsidian a command named 'Add inline geolocation link' which you can map to a keyboard shortcut.
2. Add a front matter geolocation by using the Obsidian command 'Add geolocation (front matter) to current note'. This opens the same dialog as "new geolocation note" which allows you to search for a location name or paste a [URL parsing rules](#url-parsing-rules).
3. If you have a location in some other mapping service that you wish to log, e.g. from Google Maps, you can copy the URL or "lat,lng" geolocation from that service, right-click in your note and select "Paste as Geolocation". The supported services are configurable, see [below](#url-parsing-rules) for more details.
### From the Map
The map offers several tools to create notes.
1. Use "new note here" when right-clicking the map. This will create a new note (based on the template you can change in the settings) with the location you clicked. You can create either an empty note with a front matter (single geolocation) or an empty note with an inline geolocation.
![](img/new-note.png)
Note that the map can be searched using the tool on the upper-right side.
![](img/search.png)
2. If you prefer to enter geolocations as text, use one of the "copy geolocation" options when you right-click the map. If you use "copy geolocation", just remember you need the note to start with a front matter that has an empty `locations:` line.
![](img/copy.png)
## Paste as Geolocation
Map View monitors the system clipboard, and when a URL is detected to have an encoded geolocation (e.g. a Google Maps "lat, lng" location), a "Paste as geolocation" entry is added to the editor context menu.
For example, if you right-click a location in Google Maps and click the first item in the menu (coordinates in lat,lng format), you can then paste it as a geolocation inside a note.
Alternatively, you can right-click a URL that is already present in a note and choose "Convert to geolocation".
By default Map View can parse URLs from two services: the OpenStreetMap "show address" link and a generic "lat, lng" encoding used by many URLs.
## 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.
Alternatively, if you follow an inline geolocation link by `tag:yourTagName`, this tag will be added to that geolocation in addition to the note's tags.
## Marker Icons
Map View allows you to customize notes' map marker icons based on a powerful rules system. These rules can be edited using the plugin's settings pane or edited as JSON for some even more fine-grained control.
Icons are based on [Font Awesome](https://fontawesome.com/), so to add a marker icon you'll need to find its name in the Font Awesome catalog.
Additionally, there are various marker properties (shape, color and more) that are based on [Leaflet.ExtraMarkers](https://github.com/coryasilva/Leaflet.ExtraMarkers#properties).
To change the map marker icons for your notes, go to the Map View settings and scroll to Marker Icon Rules.
A single marker is defined with a _tag pattern_ and _icon details_.
The tag pattern is usually a tag name (e.g. `#dogs`), but it can also be with a wildcard (e.g. `#trips/*`).
Icon details are a few properties: icon name (taken from the Font Awesome catalog), color and shape.
![](img/marker-rules.png)
A single marker is defined in the following JSON structure:
`{"prefix": "fas", "icon": "fa-bus", "shape": "circle", "color": "red"}`
To add a marker with a bus icon, click New Icon Rule, search Font Awesome (in the link above) for 'bus', choose [this icon](https://fontawesome.com/v5.15/icons/bus?style=solid), then see that its name is `fa-bus`.
Once you enter `fa-bus` in the icon name, you should immediately see your icon in the preview.
To make this icon apply for notes with the `#travel` tag, type `#travel` in the Tag Name box.
### Tag Rules
To apply an icon to a note with geolocation data, Map View scans the complete list of rules by their order, always starting from `default`.
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:
```
{ruleName: "default", preset: true, iconDetails: {"prefix": "fas", "icon": "fa-circle", "markerColor": "blue"}},
{ruleName: "#trip", preset: false, iconDetails: {"prefix": "fas", "icon": "fa-hiking", "markerColor": "green"}},
{ruleName: "#trip-water", preset: false, iconDetails: {"prefix": "fas", "markerColor": "blue"}},
{ruleName: "#dogs", preset: false, iconDetails: {"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.
Tag rules also support wildcards, e.g. a rule in the form of `"#food*": {...}` will match notes with the tag `#food`, `#food/pizza`, `#food/vegan`, `#food-to-try` etc.
The settings also allow advanced users to manually edit the configuration tree, and there you can use more properties based on the [Leaflet.ExtraMarkers](https://github.com/coryasilva/Leaflet.ExtraMarkers#properties) properties. Manual edits update the GUI in real-time.
## In-Note Location Search & Auto-Complete
Map View adds an Obsidian command named 'Add inline geolocation link', that you can (and encouraged) to map to a keyboard shortcut, e.g. `Ctrl+L` or `Ctrl+Shift+L`.
This command inserts an empty inline location template: `[](geo:)`.
When editing an inline location in this format, whether if you added it manually or using the command, if you start entering a link name, Map View will start offering locations based on a geocoding service.
Selecting one of the suggestions will fill-in the coordinates of the chosen locations, _not_ change your link name (assuming you prefer your own name rather than the formal one offered by the geocoding service), and jump the cursor to beyond the link so you can continue typing.
![](img/geosearch-suggest.gif)
If your note is not yet marked as one including locations (by a `locations:`) tag in the front matter, this is added automatically.
### Changing a Geocoding Provider
By default, Map View is configured to use OpenStreetMap as the search provider.
If you prefer to use the Google Maps search, you can configure this in the plugin settings.
The Google Geocoding API is practically free or very cheap for normal note-taking usage, but you'd need to setup a project and obtain an API key from Google.
See [here](https://developers.google.com/maps/documentation/javascript/get-api-key) for more details.
**Note:** usage of any geocoding provider is at your own risk, and it's your own responsibility to verify you are not violating the service's terms of usage.
## Map Sources
By default, Map View uses the [CartoDB Voyager Map](https://github.com/CartoDB/basemap-styles), which is free for up to 75K requests per month.
However, you can change or add map sources in the configuration with 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.
If you have multiple map sources, they can be switched from the View pane.
Additionally, you can set an optional different dark theme URL for each map source.
If a dark theme is detected, or if you specifically change the map source type to Dark (using the drop down in the View pane), you will get the Dark URL if one is configured.
## Presets
If there is a map state you would like to save and easily come back to, you can save it as a preset.
To do so, open the Presets pane in the main plugin's controls, and click 'Save as' to save the current view with a name you can easily go back to.
If you enter an already-existing name, that preset will be overwritten.
The saved preset includes the map state (zoom & pan), the filters used, and if you check the box in the "save as" dialog -- also the chosen map source.
If you do not include the map source as part of the preset, switching to the newly-saved preset will use the currently-selected map source.
Presets _do not_ store the map's theme (light/dark).
The Default preset is special; you can save it using the 'Save as Default' button, and come back to it by clicking the Reset button, by choosing the Default preset from the box, or by opening a fresh Map View that has no previously saved state.
## Open In
Many context menus of Map View display a customizable Open In list, which can open a given location in external sources.
These sources can be Google Maps, OpenStreetMap, specialized mapping tools or pretty much anything you use for viewing locations.
![](img/open-in.png)
The Open In list is shown:
- When right-clicking on the map.
- When right-clicking a marker on the map.
- When right-clicking a line in a note that has a location.
- In the context menu of a note that has a front matter location.
This list can be edited through the plugin's settings menu, with a name that will be displayed in the context menus and a URL pattern. The URL pattern has two parameters -- `{x}` and `{y}` -- that will be replaced by the latitude and longitude of the clicked location.
![](img/custom-open-in.png)
Popular choices may be:
- Google Maps: `https://maps.google.com/?q={x},{y}`
- OpenStreetMap: `https://www.openstreetmap.org/#map=16/{x}/{y}` (replace `16` with your preferred zoom level)
- Waze (online dropped pin): `https://ul.waze.com/ul?ll={x}%2C{y}&navigate=yes&zoom=17` (replace `17` with your preferred zoom level)
And you can figure out many other mapping services just by inspecting the URL.
## URL Parsing Rules
As described above, Map View uses _URL parsing rules_ in several places to provide the ability to parse URLs (or other strings) from external sources and convert them to standard geolocations.
1. When right-clicking a line with a recognized link, a "Convert to Geolocation" entry will be shown in the editor context menu.
2. When a recognized link is detected in the system clipboard, a "Paste as Geolocation" entry will be added in the editor context menu.
3. In the "New geolocation note" dialog, pasting a supported URL will parse the geolocation.
URL parsing rules can be configured in the plugin's configuration pane and requires familiarity with regular expressions.
The syntax expects two captures group and you can configure if they are parsed as `lat, lng` (most common) or `lng, lat`.
And if you think your added regular expressions are solid enough, please add them to the plugin using a PR so others can benefit!
![](img/url-parsing.png)
## 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...
- 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.
- A side bar with note summaries linked to the map view.
## Changelog
### 1.5.0
- Map View now saves its state to the Obsidian back/forward mechanism (unless configured not to).
- Fixed an issue with markers temporarily duplicating (until the map is refreshed) when their note is renamed.
### 1.4.0
- Replaced OpenStreetMap with CartoDB as the new default map source (https://github.com/esm7/obsidian-map-view/issues/59).
- Map View now displays a more useful error when tiles fail to load.
- Removed the "Google Maps" default URL parsing rule because apparently it was incorrect (https://github.com/esm7/obsidian-map-view/issues/57). It is now replaced by a more generic "lat,lng" rule that can also be used with Google Maps, _not_ by parsing the URL but by right-clicking the map in Google Maps and choosing the first menu item that copies the coordinates.
- Hovering on a map marker now opens the Obsidian note preview, scrolled to the correct line (https://github.com/esm7/obsidian-map-view/issues/60). This is configurable in the settings and comes _in addition_ to the existing note pop-up (without a snippet), because the preview does not include the note name.
- **Important note:** this replaces the "snippet" functionality of previous versions.
- Marker clusters now show a preview of their own; they show a popup with the first 4 icons in the cluster.
### 1.3.0
- Introduction of **Presets** as a way to save the state of the view including the map state, filters and optionally the chosen map source.
- As part of this, the 'default view' functionality was converted to a built-in 'Default' preset and the UI was revamped accordingly.
- As part of the above, the internal structure of the plugin had to be reorganized and many features needed to be rewired. **I put major effort to test the entire functionality of the plugin, but pretty much anything could break and I may have missed some bugs.** Please open issues if something had stopped working.
- When using `{{query}}` in file name templates, the file name is sanitized so the creation won't fail on illegal strings.
### 1.2.1
- The "new geolocation note" dialog can now also be used to add a location to an existing note, both via a new Obsidian command and a file menu action (https://github.com/esm7/obsidian-map-view/issues/46).
- Added a `{{query}}` file name template option to auto-add the search query for new location notes (https://github.com/esm7/obsidian-map-view/issues/45).
### 1.2.0
- Added a "new geolocation note" Obsidian command with an interactive search and URL parser.
- Fixed a bug of highlighting more than the intended line after clicking a map marker and opening a note.
- Fixed a bug of inline tags not recognized if immediately followed by a dot (which is a common pattern).
- Fixed an exception that could prevent settings to be applied after closing the settings dialog.
### 1.1.0
- The plugin now supports a configurable list of map sources, switchable from the View pane on the map, including optional separate URLs for light & dark themes.
- Support dark mode using a CSS hue revert, as [recommended for Leaflet](https://gist.github.com/BrendonKoz/b1df234fe3ee388b402cd8e98f7eedbd) and as done by obsidian-leaflet.
- [Support multiple inline locations per line](https://github.com/esm7/obsidian-map-view/issues/35).
- [Support multiple tags per inline location](https://github.com/esm7/obsidian-map-view/issues/32).
- Fixed an [issue](https://github.com/esm7/obsidian-map-view/issues/30) of inline tags not properly including the dash (`-`) character.
- Popups now have a close button (to make them more mobile-friendly).
### 1.0.0
- UI revisions and cleanups to make it easier for new users. **Some changes break existing notions.**
- Map View now treats "inline locations" as the default. It is first in the menus, and front matter actions are listed as "front matter".
- "Copy as coordinates" was removed, believing it's not really useful anymore. Please drop me a note if you find it important.
- Several other tweaks to make the plugin easier to use.
- At last, a settings UI for editing marker icons! Check it out under the plugin settings.
- In order to do this, the rule format in the plugin's data file had to be changed. The plugin will auto-convert your existing rules to the new format when first launched. After this conversion, _do not_ manually enter rules under the old key!
- You may still edit rules manually, through the plugin's "edit marker icons as JSON" box or directly in the data file, but **only use the new key** `markerIconRules`.
- Added an inline geolocation search, which can get results from either OSM or Google (with an API key), and a command to insert a geolocation.
- With relation to the above, the search tool in the map can now use the selected geocoding service too, meaning you can use Google Maps to search for locations if you have an API key.
- New "paste as geolocation" and "convert to geolocation" right-click editor menu items, that can automatically convert URLs in the clipboard or URLs in the note to a geolocation link.
- The rules that convert URLs to geolocations are configurable in the settings.

View file

@ -1,8 +1,8 @@
{
"id": "obsidian-map-view",
"name": "Map View",
"version": "1.5.0",
"minAppVersion": "0.12.10",
"description": "An interactive map view.",
"isDesktopOnly": false
}
{
"id": "obsidian-map-view",
"name": "Map View",
"version": "1.5.0",
"minAppVersion": "0.12.10",
"description": "An interactive map view.",
"isDesktopOnly": false
}

View file

@ -1,46 +1,46 @@
{
"name": "obsidian-map-view",
"version": "1.5.0",
"description": "An interactive map view for Obsidian.md",
"main": "main.js",
"scripts": {
"dev": "rollup --config rollup.config.js -w --environment BUILD:development",
"dev-dist": "rollup --config rollup.config.js -w",
"build": "rollup --config rollup.config.js --environment BUILD:production",
"prettier": "npx prettier --write .",
"stylecheck": "npx prettier --check ."
},
"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/leaflet.markercluster": "^1.4.5",
"@types/node": "^14.14.37",
"obsidian": "^0.12.5",
"postcss-less": "^4.0.1",
"postcss-url": "^10.1.3",
"prettier": "2.6.0",
"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",
"leaflet.markercluster": "^1.5.3",
"moment": "^2.29.1",
"open": "^8.2.1",
"wildcard": "^2.0.0"
}
"name": "obsidian-map-view",
"version": "1.5.0",
"description": "An interactive map view for Obsidian.md",
"main": "main.js",
"scripts": {
"dev": "rollup --config rollup.config.js -w --environment BUILD:development",
"dev-dist": "rollup --config rollup.config.js -w",
"build": "rollup --config rollup.config.js --environment BUILD:production",
"prettier": "npx prettier --write .",
"stylecheck": "npx prettier --check ."
},
"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/leaflet.markercluster": "^1.4.5",
"@types/node": "^14.14.37",
"obsidian": "^0.12.5",
"postcss-less": "^4.0.1",
"postcss-url": "^10.1.3",
"prettier": "2.6.0",
"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",
"leaflet.markercluster": "^1.5.3",
"moment": "^2.29.1",
"open": "^8.2.1",
"wildcard": "^2.0.0"
}
}

View file

@ -1,42 +1,46 @@
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: process.env.BUILD === 'development' ? '.' : './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'})] }),
...(process.env.BUILD !== 'development' ? [
copy({
targets: [
{ src: './manifest.json', dest: 'dist' },
{ src: './styles.css', 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: process.env.BUILD === 'development' ? '.' : './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' })],
}),
...(process.env.BUILD !== 'development'
? [
copy({
targets: [
{ src: './manifest.json', dest: 'dist' },
{ src: './styles.css', dest: 'dist' },
],
}),
]
: []),
],
};

View file

@ -3,10 +3,16 @@ import * as leaflet from 'leaflet';
export const MAP_VIEW_NAME = 'map';
// SVG editor used: https://svgedit.netlify.app/editor/index.html
export const RIBBON_ICON = '<path fill="currentColor" stroke="currentColor" d="m50.06001,1.76c-26.54347,0 -48.06001,21.74039 -48.06001,48.56c0,26.81961 21.51654,48.56 48.06001,48.56c26.54347,0 48.06001,-21.74039 48.06001,-48.56c0,-26.81961 -21.51654,-48.56 -48.06001,-48.56zm15.94701,70.02039c-0.75578,0.75973 -1.54838,1.55666 -2.19177,2.2087c-0.57943,0.58742 -0.98833,1.3119 -1.19569,2.09709c-0.29262,1.10826 -0.52905,2.22828 -0.92438,3.30325l-3.37001,9.17353c-2.66656,0.58742 -5.42613,0.91833 -8.26516,0.91833l0,-5.36118c0.32751,-2.47108 -1.48056,-7.09994 -4.38548,-10.03508c-1.16274,-1.17484 -1.81582,-2.7687 -1.81582,-4.4311l0,-6.26776c0,-2.27919 -1.21507,-4.37432 -3.18979,-5.47671c-2.78477,-1.55666 -6.74584,-3.73207 -9.45891,-5.11251c-2.22471,-1.13176 -4.28277,-2.5729 -6.13346,-4.25879l-0.15503,-0.14098c-1.32339,-1.20715 -2.49854,-2.57055 -3.49985,-4.06103c-1.81775,-2.69625 -4.77887,-7.13127 -6.70321,-10.01354c3.96689,-8.90919 11.11582,-16.06396 19.99917,-19.95072l4.65291,2.35164c2.06193,1.04169 4.48818,-0.47189 4.48818,-2.80199l0,-2.21261c1.54838,-0.25259 3.1239,-0.41315 4.72655,-0.47385l5.48427,5.54132c1.21119,1.22379 1.21119,3.20731 0,4.4311l-0.90888,0.91637l-2.00379,2.02464c-0.60463,0.61092 -0.60463,1.60365 0,2.21457l0.90888,0.91833c0.60463,0.61092 0.60463,1.60365 0,2.21457l-1.55032,1.56645c-0.29107,0.29351 -0.68563,0.45838 -1.09685,0.45819l-1.74218,0c-0.40308,0 -0.79066,0.1586 -1.08135,0.44448l-1.9224,1.88953c-0.48351,0.47581 -0.60734,1.21263 -0.30619,1.82296l3.02119,6.1072c0.51548,1.04169 -0.23449,2.26744 -1.3856,2.26744l-1.09298,0c-0.37402,0 -0.73447,-0.13706 -1.01546,-0.38378l-1.79837,-1.5782c-0.82787,-0.72566 -1.97337,-0.95651 -3.01344,-0.607l-6.04045,2.03443c-0.9457,0.31858 -1.58365,1.21302 -1.58327,2.22045c0,0.887 0.4961,1.69568 1.28095,2.09317l2.1472,1.08477c1.82357,0.92225 3.83511,1.40197 5.87379,1.40197c2.03867,0 4.37772,5.34356 6.20129,6.26581l12.93551,0c1.64528,0 3.2208,0.65987 4.38548,1.83471l2.65299,2.68059c1.10829,1.12021 1.73074,2.63947 1.73055,4.22355c-0.00078,2.42428 -0.95771,4.74811 -2.6588,6.4577zm16.80356,-17.88692c-1.12205,-0.28392 -2.10069,-0.97903 -2.74213,-1.95219l-3.48435,-5.2809c-1.04259,-1.57781 -1.04259,-3.63456 0,-5.21237l3.79635,-5.75279c0.44959,-0.67945 1.06585,-1.23162 1.79062,-1.59582l2.5154,-1.27078c2.62005,5.27111 4.13161,11.20013 4.13161,17.49139c0,1.69764 -0.1434,3.36004 -0.3527,5.0009l-5.6548,-1.42743z" fill="#000000" id="shape0" stroke="#000000" stroke-linecap="square" stroke-linejoin="bevel" stroke-opacity="0" stroke-width="0"/>';
export const RIBBON_ICON =
'<path fill="currentColor" stroke="currentColor" d="m50.06001,1.76c-26.54347,0 -48.06001,21.74039 -48.06001,48.56c0,26.81961 21.51654,48.56 48.06001,48.56c26.54347,0 48.06001,-21.74039 48.06001,-48.56c0,-26.81961 -21.51654,-48.56 -48.06001,-48.56zm15.94701,70.02039c-0.75578,0.75973 -1.54838,1.55666 -2.19177,2.2087c-0.57943,0.58742 -0.98833,1.3119 -1.19569,2.09709c-0.29262,1.10826 -0.52905,2.22828 -0.92438,3.30325l-3.37001,9.17353c-2.66656,0.58742 -5.42613,0.91833 -8.26516,0.91833l0,-5.36118c0.32751,-2.47108 -1.48056,-7.09994 -4.38548,-10.03508c-1.16274,-1.17484 -1.81582,-2.7687 -1.81582,-4.4311l0,-6.26776c0,-2.27919 -1.21507,-4.37432 -3.18979,-5.47671c-2.78477,-1.55666 -6.74584,-3.73207 -9.45891,-5.11251c-2.22471,-1.13176 -4.28277,-2.5729 -6.13346,-4.25879l-0.15503,-0.14098c-1.32339,-1.20715 -2.49854,-2.57055 -3.49985,-4.06103c-1.81775,-2.69625 -4.77887,-7.13127 -6.70321,-10.01354c3.96689,-8.90919 11.11582,-16.06396 19.99917,-19.95072l4.65291,2.35164c2.06193,1.04169 4.48818,-0.47189 4.48818,-2.80199l0,-2.21261c1.54838,-0.25259 3.1239,-0.41315 4.72655,-0.47385l5.48427,5.54132c1.21119,1.22379 1.21119,3.20731 0,4.4311l-0.90888,0.91637l-2.00379,2.02464c-0.60463,0.61092 -0.60463,1.60365 0,2.21457l0.90888,0.91833c0.60463,0.61092 0.60463,1.60365 0,2.21457l-1.55032,1.56645c-0.29107,0.29351 -0.68563,0.45838 -1.09685,0.45819l-1.74218,0c-0.40308,0 -0.79066,0.1586 -1.08135,0.44448l-1.9224,1.88953c-0.48351,0.47581 -0.60734,1.21263 -0.30619,1.82296l3.02119,6.1072c0.51548,1.04169 -0.23449,2.26744 -1.3856,2.26744l-1.09298,0c-0.37402,0 -0.73447,-0.13706 -1.01546,-0.38378l-1.79837,-1.5782c-0.82787,-0.72566 -1.97337,-0.95651 -3.01344,-0.607l-6.04045,2.03443c-0.9457,0.31858 -1.58365,1.21302 -1.58327,2.22045c0,0.887 0.4961,1.69568 1.28095,2.09317l2.1472,1.08477c1.82357,0.92225 3.83511,1.40197 5.87379,1.40197c2.03867,0 4.37772,5.34356 6.20129,6.26581l12.93551,0c1.64528,0 3.2208,0.65987 4.38548,1.83471l2.65299,2.68059c1.10829,1.12021 1.73074,2.63947 1.73055,4.22355c-0.00078,2.42428 -0.95771,4.74811 -2.6588,6.4577zm16.80356,-17.88692c-1.12205,-0.28392 -2.10069,-0.97903 -2.74213,-1.95219l-3.48435,-5.2809c-1.04259,-1.57781 -1.04259,-3.63456 0,-5.21237l3.79635,-5.75279c0.44959,-0.67945 1.06585,-1.23162 1.79062,-1.59582l2.5154,-1.27078c2.62005,5.27111 4.13161,11.20013 4.13161,17.49139c0,1.69764 -0.1434,3.36004 -0.3527,5.0009l-5.6548,-1.42743z" fill="#000000" id="shape0" stroke="#000000" stroke-linecap="square" stroke-linejoin="bevel" stroke-opacity="0" stroke-width="0"/>';
export const TILES_URL_OPENSTREETMAP = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
export const SEARCH_RESULT_MARKER = {prefix: 'fas', icon: 'fa-search', markerColor: 'blue'};
export const TILES_URL_OPENSTREETMAP =
'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
export const SEARCH_RESULT_MARKER = {
prefix: 'fas',
icon: 'fa-search',
markerColor: 'blue',
};
export const MAX_CLUSTER_PREVIEW_ICONS = 4;
export const HISTORY_SAVE_ZOOM_DIFF = 2;

View file

@ -1,4 +1,13 @@
import { App, Editor, Notice, EditorSuggest, EditorPosition, TFile, EditorSuggestTriggerInfo, EditorSuggestContext } from 'obsidian';
import {
App,
Editor,
Notice,
EditorSuggest,
EditorPosition,
TFile,
EditorSuggestTriggerInfo,
EditorSuggestContext,
} from 'obsidian';
import * as geosearch from 'leaflet-geosearch';
import * as leaflet from 'leaflet';
@ -6,105 +15,136 @@ import * as utils from 'src/utils';
import { PluginSettings } from 'src/settings';
export class SuggestInfo {
name: string;
location: leaflet.LatLng;
context: EditorSuggestContext;
};
name: string;
location: leaflet.LatLng;
context: EditorSuggestContext;
}
export class LocationSuggest extends EditorSuggest<SuggestInfo> {
private cursorInsideGeolinkFinder = /\[(.*?)\]\(geo:.*?\)/g;
public searchProvider: geosearch.OpenStreetMapProvider | geosearch.GoogleProvider = null;
private lastSearchTime = 0;
private delayInMs = 250;
private cursorInsideGeolinkFinder = /\[(.*?)\]\(geo:.*?\)/g;
public searchProvider:
| geosearch.OpenStreetMapProvider
| geosearch.GoogleProvider = null;
private lastSearchTime = 0;
private delayInMs = 250;
constructor(app: App, settings: PluginSettings) {
super(app);
if (settings.searchProvider == 'osm')
this.searchProvider = new geosearch.OpenStreetMapProvider();
else if (settings.searchProvider == 'google') {
this.searchProvider = new geosearch.GoogleProvider({params: {key: settings.geocodingApiKey}});
}
}
constructor(app: App, settings: PluginSettings) {
super(app);
if (settings.searchProvider == 'osm')
this.searchProvider = new geosearch.OpenStreetMapProvider();
else if (settings.searchProvider == 'google') {
this.searchProvider = new geosearch.GoogleProvider({
params: { key: settings.geocodingApiKey },
});
}
}
onTrigger(cursor: EditorPosition, editor: Editor, file: TFile) : EditorSuggestTriggerInfo | null {
const currentLink = this.getGeolinkOfCursor(cursor, editor);
if (currentLink)
return {
start: {line: cursor.line, ch: currentLink.index},
end: {line: cursor.line, ch: currentLink.linkEnd},
query: currentLink.name
};
return null;
}
onTrigger(
cursor: EditorPosition,
editor: Editor,
file: TFile
): EditorSuggestTriggerInfo | null {
const currentLink = this.getGeolinkOfCursor(cursor, editor);
if (currentLink)
return {
start: { line: cursor.line, ch: currentLink.index },
end: { line: cursor.line, ch: currentLink.linkEnd },
query: currentLink.name,
};
return null;
}
async getSuggestions(context: EditorSuggestContext): Promise<SuggestInfo[]> {
if (context.query.length < 2)
return [];
return await this.getSeachResultWithDelay(context);
}
async getSuggestions(
context: EditorSuggestContext
): Promise<SuggestInfo[]> {
if (context.query.length < 2) return [];
return await this.getSeachResultWithDelay(context);
}
renderSuggestion(value: SuggestInfo, el: HTMLElement) {
el.setText(value.name);
}
renderSuggestion(value: SuggestInfo, el: HTMLElement) {
el.setText(value.name);
}
selectSuggestion(value: SuggestInfo, evt: MouseEvent | KeyboardEvent) {
// Replace the link under the cursor with the retrieved location.
// We call getGeolinkOfCursor again instead of using the original context because it's possible that
// the user continued to type text after the suggestion was made
const currentCursor = value.context.editor.getCursor();
const linkOfCursor = this.getGeolinkOfCursor(currentCursor, value.context.editor);
const finalResult = `[${value.context.query}](geo:${value.location.lat},${value.location.lng})`;
value.context.editor.replaceRange(
finalResult,
{line: currentCursor.line, ch: linkOfCursor.index},
{line: currentCursor.line, ch: linkOfCursor.linkEnd});
if (utils.verifyOrAddFrontMatter(value.context.editor, 'locations', ''))
new Notice("The note's front matter was updated to denote locations are present");
}
selectSuggestion(value: SuggestInfo, evt: MouseEvent | KeyboardEvent) {
// Replace the link under the cursor with the retrieved location.
// We call getGeolinkOfCursor again instead of using the original context because it's possible that
// the user continued to type text after the suggestion was made
const currentCursor = value.context.editor.getCursor();
const linkOfCursor = this.getGeolinkOfCursor(
currentCursor,
value.context.editor
);
const finalResult = `[${value.context.query}](geo:${value.location.lat},${value.location.lng})`;
value.context.editor.replaceRange(
finalResult,
{ line: currentCursor.line, ch: linkOfCursor.index },
{ line: currentCursor.line, ch: linkOfCursor.linkEnd }
);
if (utils.verifyOrAddFrontMatter(value.context.editor, 'locations', ''))
new Notice(
"The note's front matter was updated to denote locations are present"
);
}
getGeolinkOfCursor(cursor: EditorPosition, editor: Editor) {
const results = editor.getLine(cursor.line).matchAll(this.cursorInsideGeolinkFinder);
if (!results)
return null;
for (let result of results) {
const linkName = result[1];
if (cursor.ch >= result.index && cursor.ch < result.index + linkName.length + 2)
return {index: result.index, name: linkName, linkEnd: result.index + result[0].length};
}
return null;
}
getGeolinkOfCursor(cursor: EditorPosition, editor: Editor) {
const results = editor
.getLine(cursor.line)
.matchAll(this.cursorInsideGeolinkFinder);
if (!results) return null;
for (let result of results) {
const linkName = result[1];
if (
cursor.ch >= result.index &&
cursor.ch < result.index + linkName.length + 2
)
return {
index: result.index,
name: linkName,
linkEnd: result.index + result[0].length,
};
}
return null;
}
async getSeachResultWithDelay(context: EditorSuggestContext): Promise<SuggestInfo[] | null> {
const timestamp = Date.now();
this.lastSearchTime = timestamp;
const Sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
await Sleep(this.delayInMs);
if (this.lastSearchTime != timestamp) {
// Search is canceled by a newer search
return null;
}
// After the sleep our search is still the last -- so the user stopped and we can go on
const results = await this.searchProvider.search({query: context.query});
const suggestions = results.map(result => ({
name: result.label,
location: new leaflet.LatLng(result.y, result.x),
context: context
}));
return suggestions;
}
async getSeachResultWithDelay(
context: EditorSuggestContext
): Promise<SuggestInfo[] | null> {
const timestamp = Date.now();
this.lastSearchTime = timestamp;
const Sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
await Sleep(this.delayInMs);
if (this.lastSearchTime != timestamp) {
// Search is canceled by a newer search
return null;
}
// After the sleep our search is still the last -- so the user stopped and we can go on
const results = await this.searchProvider.search({
query: context.query,
});
const suggestions = results.map((result) => ({
name: result.label,
location: new leaflet.LatLng(result.y, result.x),
context: context,
}));
return suggestions;
}
async selectionToLink(editor: Editor) {
const selection = editor.getSelection();
const results = await this.searchProvider.search({query: selection});
if (results && results.length > 0) {
const firstResult = results[0];
editor.replaceSelection(`[${selection}](geo:${firstResult.y},${firstResult.x})`);
new Notice(firstResult.label, 10 * 1000);
if (utils.verifyOrAddFrontMatter(editor, 'locations', ''))
new Notice("The note's front matter was updated to denote locations are present");
}
else {
new Notice(`No location found for the term '${selection}'`);
}
}
async selectionToLink(editor: Editor) {
const selection = editor.getSelection();
const results = await this.searchProvider.search({ query: selection });
if (results && results.length > 0) {
const firstResult = results[0];
editor.replaceSelection(
`[${selection}](geo:${firstResult.y},${firstResult.x})`
);
new Notice(firstResult.label, 10 * 1000);
if (utils.verifyOrAddFrontMatter(editor, 'locations', ''))
new Notice(
"The note's front matter was updated to denote locations are present"
);
} else {
new Notice(`No location found for the term '${selection}'`);
}
}
}

View file

@ -1,269 +1,374 @@
import { addIcon, Notice, Editor, FileView, MarkdownView, MenuItem, Menu, TFile, Plugin, WorkspaceLeaf, TAbstractFile } from 'obsidian';
import * as consts from 'src/consts';
import * as leaflet from 'leaflet';
import { LocationSuggest } from 'src/geosearch';
import { UrlConvertor } from 'src/urlConvertor';
import { MapView } from 'src/mapView';
import { PluginSettings, DEFAULT_SETTINGS, convertLegacyMarkerIcons, convertLegacyTilesUrl, convertLegacyDefaultState, removeLegacyPresets1, MapState } from 'src/settings';
import { getFrontMatterLocation, matchInlineLocation, verifyLocation } from 'src/markers';
import { SettingsTab } from 'src/settingsTab';
import { NewNoteDialog } from 'src/newNoteDialog';
import * as utils from 'src/utils';
export default class MapViewPlugin extends Plugin {
settings: PluginSettings;
public highestVersionSeen: number = 0;
private suggestor: LocationSuggest;
private urlConvertor: UrlConvertor;
async onload() {
addIcon('globe', consts.RIBBON_ICON);
await this.loadSettings();
// Add a new ribbon entry to the left bar
this.addRibbonIcon('globe', 'Open map view', () => {
// When clicked change the active view to the map
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.suggestor = new LocationSuggest(this.app, this.settings);
this.urlConvertor = new UrlConvertor(this.app, this.settings);
this.registerEditorSuggest(this.suggestor);
// Convert old settings formats that are no longer supported
if (convertLegacyMarkerIcons(this.settings)) {
await this.saveSettings();
new Notice("Map View: legacy marker icons were converted to the new format");
}
if (convertLegacyTilesUrl(this.settings)) {
await this.saveSettings();
new Notice("Map View: legacy tiles URL was converted to the new format");
}
if (convertLegacyDefaultState(this.settings)) {
await this.saveSettings();
new Notice("Map View: legacy default state was converted to the new format");
}
if (removeLegacyPresets1(this.settings)) {
await this.saveSettings();
new Notice("Map View: legacy URL parsing rules and/or map sources were converted. See the release notes");
}
// Register commands to the command palette
// Command that opens the map view (same as clicking the map icon)
this.addCommand({
id: 'open-map-view',
name: 'Open Map View',
callback: () => {
this.app.workspace.getLeaf().setViewState({type: consts.MAP_VIEW_NAME});
},
});
// Command that looks up the selected text to find the location
this.addCommand({
id: 'convert-selection-to-location',
name: 'Convert Selection to Geolocation',
editorCheckCallback: (checking, editor, view) => {
if (checking)
return editor.getSelection().length > 0;
this.suggestor.selectionToLink(editor);
}
});
// Command that adds a blank inline location at the cursor location
this.addCommand({
id: 'insert-geolink',
name: 'Add inline geolocation link',
editorCallback: (editor, view) => {
const positionBeforeInsert = editor.getCursor();
editor.replaceSelection('[](geo:)');
editor.setCursor({line: positionBeforeInsert.line, ch: positionBeforeInsert.ch + 1});
}
});
// Command that opens the location search dialog and creates a new note from this location
this.addCommand({
id: 'new-geolocation-note',
name: 'New geolocation note',
callback: () => {
const dialog = new NewNoteDialog(this.app, this.settings);
dialog.open();
}
});
// Command that opens the location search dialog and adds the location to the current note
this.addCommand({
id: 'add-frontmatter-geolocation',
name: 'Add geolocation (front matter) to current note',
editorCallback: (editor, view) => {
const dialog = new NewNoteDialog(this.app, this.settings, 'addToNote', editor);
dialog.open();
}
});
this.addSettingTab(new SettingsTab(this.app, this));
// Add items to the file context menu (run when the context menu is built)
// This is the context menu in the File Explorer and clicking "More options" (three dots) from within a file.
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) {
// If there is a geolocation in the front matter of the file
// Add an option to open it in the map
menu.addItem((item: MenuItem) => {
item.setTitle('Show on map');
item.setIcon('globe');
item.onClick(async (evt: MouseEvent) => await this.openMapWithLocation(location, evt.ctrlKey));
});
// Add an option to open it in the default app
menu.addItem((item: MenuItem) => {
item.setTitle('Open with default app');
item.onClick(_ev => {
open(`geo:${location.lat},${location.lng}`);
});
});
// Populate menu items from user defined "Open In" strings
utils.populateOpenInItems(menu, location, this.settings);
} else {
if (leaf && leaf.view instanceof MarkdownView) {
// If there is no valid geolocation in the front matter, add a menu item to populate it.
const editor = leaf.view.editor;
menu.addItem((item: MenuItem) => {
item.setTitle('Add geolocation (front matter)');
item.setIcon('globe');
item.onClick(async (evt: MouseEvent) => {
const dialog = new NewNoteDialog(this.app, this.settings, 'addToNote', editor);
dialog.open();
});
});
}
}
}
});
// Add items to the editor context menu (run when the context menu is built)
// This is the context menu when right clicking within an editor view.
this.app.workspace.on('editor-menu', async (menu: Menu, editor: Editor, view: MarkdownView) => {
if (view instanceof FileView) {
const location = this.getLocationOnEditorLine(editor, view);
if (location) {
// If there is a geolocation on the line
// Add an option to open it in the map
menu.addItem((item: MenuItem) => {
item.setTitle('Show on map');
item.setIcon('globe');
item.onClick(async (evt: MouseEvent) => await this.openMapWithLocation(location, evt.ctrlKey));
});
// Add an option to open it in the default app
menu.addItem((item: MenuItem) => {
item.setTitle('Open with default app');
item.onClick(_ev => {
open(`geo:${location.lat},${location.lng}`);
});
});
// Populate menu items from user defined "Open In" strings
utils.populateOpenInItems(menu, location, this.settings);
}
if (editor.getSelection()) {
// If there is text selected, add a menu item to convert it to coordinates using geosearch
menu.addItem((item: MenuItem) => {
item.setTitle('Convert to geolocation (geosearch)');
item.onClick(async () => await this.suggestor.selectionToLink(editor));
});
}
if (this.urlConvertor.findMatchInLine(editor))
// If the line contains a recognized geolocation that can be converted from a URL parsing rule
menu.addItem((item: MenuItem) => {
item.setTitle('Convert to geolocation');
item.onClick(async () => {
this.urlConvertor.convertUrlAtCursorToGeolocation(editor);
});
})
const clipboard = await navigator.clipboard.readText();
const clipboardLocation = this.urlConvertor.parseLocationFromUrl(clipboard)?.location;
if (clipboardLocation) {
// If the clipboard contains a recognized geolocation that can be converted from a URL parsing rule
menu.addItem((item: MenuItem) => {
item.setTitle('Paste as geolocation');
item.onClick(async () => {
this.urlConvertor.insertLocationToEditor(clipboardLocation, editor);
});
})
}
}
});
}
/**
* Open an instance of the map at the given geolocation
* @param location The geolocation to open the map at
* @param ctrlKey Was the control key pressed
*/
private async openMapWithLocation(location: leaflet.LatLng, ctrlKey: boolean) {
await this.openMapWithState({mapCenter: location, mapZoom: this.settings.zoomOnGoFromNote} as MapState, ctrlKey);
}
private async openMapWithState(state: MapState, ctrlKey: boolean) {
// Find the best candidate for a leaf to open the map view on.
// If there's an open map view, use that, otherwise use the current leaf.
// If Ctrl is pressed, override that behavior and always use the current leaf.
const maps = this.app.workspace.getLeavesOfType(consts.MAP_VIEW_NAME);
let chosenLeaf: WorkspaceLeaf = null;
if (maps && !ctrlKey)
chosenLeaf = maps[0];
else
chosenLeaf = this.app.workspace.getLeaf();
if (!chosenLeaf)
chosenLeaf = this.app.workspace.activeLeaf;
await chosenLeaf.setViewState({type: consts.MAP_VIEW_NAME, state: state});
}
/**
* Get the geolocation on the current editor line
* @param editor obsidian Editor instance
* @param view obsidian FileView instance
* @private
*/
private getLocationOnEditorLine(editor: Editor, view: FileView): leaflet.LatLng {
const line = editor.getLine(editor.getCursor().line);
const match = matchInlineLocation(line)[0];
let selectedLocation = null;
if (match)
selectedLocation = new leaflet.LatLng(parseFloat(match[2]), parseFloat(match[3]));
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() {
}
/** Initialise the plugin settings from Obsidian's cache */
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
/** Save the plugin settings to Obsidian's cache so it can be reused later. */
async saveSettings() {
await this.saveData(this.settings);
}
}
import {
addIcon,
Notice,
Editor,
FileView,
MarkdownView,
MenuItem,
Menu,
TFile,
Plugin,
WorkspaceLeaf,
TAbstractFile,
} from 'obsidian';
import * as consts from 'src/consts';
import * as leaflet from 'leaflet';
import { LocationSuggest } from 'src/geosearch';
import { UrlConvertor } from 'src/urlConvertor';
import { MapView } from 'src/mapView';
import {
PluginSettings,
DEFAULT_SETTINGS,
convertLegacyMarkerIcons,
convertLegacyTilesUrl,
convertLegacyDefaultState,
removeLegacyPresets1,
MapState,
} from 'src/settings';
import {
getFrontMatterLocation,
matchInlineLocation,
verifyLocation,
} from 'src/markers';
import { SettingsTab } from 'src/settingsTab';
import { NewNoteDialog } from 'src/newNoteDialog';
import * as utils from 'src/utils';
export default class MapViewPlugin extends Plugin {
settings: PluginSettings;
public highestVersionSeen: number = 0;
private suggestor: LocationSuggest;
private urlConvertor: UrlConvertor;
async onload() {
addIcon('globe', consts.RIBBON_ICON);
await this.loadSettings();
// Add a new ribbon entry to the left bar
this.addRibbonIcon('globe', 'Open map view', () => {
// When clicked change the active view to the map
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.suggestor = new LocationSuggest(this.app, this.settings);
this.urlConvertor = new UrlConvertor(this.app, this.settings);
this.registerEditorSuggest(this.suggestor);
// Convert old settings formats that are no longer supported
if (convertLegacyMarkerIcons(this.settings)) {
await this.saveSettings();
new Notice(
'Map View: legacy marker icons were converted to the new format'
);
}
if (convertLegacyTilesUrl(this.settings)) {
await this.saveSettings();
new Notice(
'Map View: legacy tiles URL was converted to the new format'
);
}
if (convertLegacyDefaultState(this.settings)) {
await this.saveSettings();
new Notice(
'Map View: legacy default state was converted to the new format'
);
}
if (removeLegacyPresets1(this.settings)) {
await this.saveSettings();
new Notice(
'Map View: legacy URL parsing rules and/or map sources were converted. See the release notes'
);
}
// Register commands to the command palette
// Command that opens the map view (same as clicking the map icon)
this.addCommand({
id: 'open-map-view',
name: 'Open Map View',
callback: () => {
this.app.workspace
.getLeaf()
.setViewState({ type: consts.MAP_VIEW_NAME });
},
});
// Command that looks up the selected text to find the location
this.addCommand({
id: 'convert-selection-to-location',
name: 'Convert Selection to Geolocation',
editorCheckCallback: (checking, editor, view) => {
if (checking) return editor.getSelection().length > 0;
this.suggestor.selectionToLink(editor);
},
});
// Command that adds a blank inline location at the cursor location
this.addCommand({
id: 'insert-geolink',
name: 'Add inline geolocation link',
editorCallback: (editor, view) => {
const positionBeforeInsert = editor.getCursor();
editor.replaceSelection('[](geo:)');
editor.setCursor({
line: positionBeforeInsert.line,
ch: positionBeforeInsert.ch + 1,
});
},
});
// Command that opens the location search dialog and creates a new note from this location
this.addCommand({
id: 'new-geolocation-note',
name: 'New geolocation note',
callback: () => {
const dialog = new NewNoteDialog(this.app, this.settings);
dialog.open();
},
});
// Command that opens the location search dialog and adds the location to the current note
this.addCommand({
id: 'add-frontmatter-geolocation',
name: 'Add geolocation (front matter) to current note',
editorCallback: (editor, view) => {
const dialog = new NewNoteDialog(
this.app,
this.settings,
'addToNote',
editor
);
dialog.open();
},
});
this.addSettingTab(new SettingsTab(this.app, this));
// Add items to the file context menu (run when the context menu is built)
// This is the context menu in the File Explorer and clicking "More options" (three dots) from within a file.
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) {
// If there is a geolocation in the front matter of the file
// Add an option to open it in the map
menu.addItem((item: MenuItem) => {
item.setTitle('Show on map');
item.setIcon('globe');
item.onClick(
async (evt: MouseEvent) =>
await this.openMapWithLocation(
location,
evt.ctrlKey
)
);
});
// Add an option to open it in the default app
menu.addItem((item: MenuItem) => {
item.setTitle('Open with default app');
item.onClick((_ev) => {
open(`geo:${location.lat},${location.lng}`);
});
});
// Populate menu items from user defined "Open In" strings
utils.populateOpenInItems(
menu,
location,
this.settings
);
} else {
if (leaf && leaf.view instanceof MarkdownView) {
// If there is no valid geolocation in the front matter, add a menu item to populate it.
const editor = leaf.view.editor;
menu.addItem((item: MenuItem) => {
item.setTitle('Add geolocation (front matter)');
item.setIcon('globe');
item.onClick(async (evt: MouseEvent) => {
const dialog = new NewNoteDialog(
this.app,
this.settings,
'addToNote',
editor
);
dialog.open();
});
});
}
}
}
}
);
// Add items to the editor context menu (run when the context menu is built)
// This is the context menu when right clicking within an editor view.
this.app.workspace.on(
'editor-menu',
async (menu: Menu, editor: Editor, view: MarkdownView) => {
if (view instanceof FileView) {
const location = this.getLocationOnEditorLine(editor, view);
if (location) {
// If there is a geolocation on the line
// Add an option to open it in the map
menu.addItem((item: MenuItem) => {
item.setTitle('Show on map');
item.setIcon('globe');
item.onClick(
async (evt: MouseEvent) =>
await this.openMapWithLocation(
location,
evt.ctrlKey
)
);
});
// Add an option to open it in the default app
menu.addItem((item: MenuItem) => {
item.setTitle('Open with default app');
item.onClick((_ev) => {
open(`geo:${location.lat},${location.lng}`);
});
});
// Populate menu items from user defined "Open In" strings
utils.populateOpenInItems(
menu,
location,
this.settings
);
}
if (editor.getSelection()) {
// If there is text selected, add a menu item to convert it to coordinates using geosearch
menu.addItem((item: MenuItem) => {
item.setTitle('Convert to geolocation (geosearch)');
item.onClick(
async () =>
await this.suggestor.selectionToLink(editor)
);
});
}
if (this.urlConvertor.findMatchInLine(editor))
// If the line contains a recognized geolocation that can be converted from a URL parsing rule
menu.addItem((item: MenuItem) => {
item.setTitle('Convert to geolocation');
item.onClick(async () => {
this.urlConvertor.convertUrlAtCursorToGeolocation(
editor
);
});
});
const clipboard = await navigator.clipboard.readText();
const clipboardLocation =
this.urlConvertor.parseLocationFromUrl(
clipboard
)?.location;
if (clipboardLocation) {
// If the clipboard contains a recognized geolocation that can be converted from a URL parsing rule
menu.addItem((item: MenuItem) => {
item.setTitle('Paste as geolocation');
item.onClick(async () => {
this.urlConvertor.insertLocationToEditor(
clipboardLocation,
editor
);
});
});
}
}
}
);
}
/**
* Open an instance of the map at the given geolocation
* @param location The geolocation to open the map at
* @param ctrlKey Was the control key pressed
*/
private async openMapWithLocation(
location: leaflet.LatLng,
ctrlKey: boolean
) {
await this.openMapWithState(
{
mapCenter: location,
mapZoom: this.settings.zoomOnGoFromNote,
} as MapState,
ctrlKey
);
}
private async openMapWithState(state: MapState, ctrlKey: boolean) {
// Find the best candidate for a leaf to open the map view on.
// If there's an open map view, use that, otherwise use the current leaf.
// If Ctrl is pressed, override that behavior and always use the current leaf.
const maps = this.app.workspace.getLeavesOfType(consts.MAP_VIEW_NAME);
let chosenLeaf: WorkspaceLeaf = null;
if (maps && !ctrlKey) chosenLeaf = maps[0];
else chosenLeaf = this.app.workspace.getLeaf();
if (!chosenLeaf) chosenLeaf = this.app.workspace.activeLeaf;
await chosenLeaf.setViewState({
type: consts.MAP_VIEW_NAME,
state: state,
});
}
/**
* Get the geolocation on the current editor line
* @param editor obsidian Editor instance
* @param view obsidian FileView instance
* @private
*/
private getLocationOnEditorLine(
editor: Editor,
view: FileView
): leaflet.LatLng {
const line = editor.getLine(editor.getCursor().line);
const match = matchInlineLocation(line)[0];
let selectedLocation = null;
if (match)
selectedLocation = new leaflet.LatLng(
parseFloat(match[2]),
parseFloat(match[3])
);
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() {}
/** Initialise the plugin settings from Obsidian's cache */
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData()
);
}
/** Save the plugin settings to Obsidian's cache so it can be reused later. */
async saveSettings() {
await this.saveData(this.settings);
}
}

File diff suppressed because it is too large Load diff

View file

@ -14,54 +14,61 @@ type MarkerId = string;
/** An object that represents a single marker in a file, which is either a complete note with a geolocation, or an inline geolocation inside a note */
export class FileMarker {
/** The file object on which this location was found */
file: TFile;
/** In the case of an inline location, the position within the file where the location was found */
fileLocation?: number;
/** In case of an inline location, the line within the file where the geolocation was found */
fileLine?: number;
location: leaflet.LatLng;
icon?: leaflet.Icon<leaflet.BaseIconOptions>;
mapMarker?: leaflet.Marker;
/** An ID to recognize the marker */
id: MarkerId;
/** Snippet of the file to show in the map marker popup */
snippet?: string;
/** Optional extra name that can be set for geolocation links (this is the link name rather than the file name) */
extraName?: string;
/** Tags that this marker includes */
tags: string[] = [];
/** The file object on which this location was found */
file: TFile;
/** In the case of an inline location, the position within the file where the location was found */
fileLocation?: number;
/** In case of an inline location, the line within the file where the geolocation was found */
fileLine?: number;
location: leaflet.LatLng;
icon?: leaflet.Icon<leaflet.BaseIconOptions>;
mapMarker?: leaflet.Marker;
/** An ID to recognize the marker */
id: MarkerId;
/** Snippet of the file to show in the map marker popup */
snippet?: string;
/** Optional extra name that can be set for geolocation links (this is the link name rather than the file name) */
extraName?: string;
/** Tags that this marker includes */
tags: string[] = [];
/**
* Construct a new FileMarker object
* @param file The file the pin comes from
* @param location The geolocation
*/
constructor(file: TFile, location: leaflet.LatLng) {
this.file = file;
this.location = location;
this.id = this.generateId();
}
/**
* Construct a new FileMarker object
* @param file The file the pin comes from
* @param location The geolocation
*/
constructor(file: TFile, location: leaflet.LatLng) {
this.file = file;
this.location = location;
this.id = this.generateId();
}
isSame(other: FileMarker) {
return this.file.name === other.file.name &&
this.location.toString() === other.location.toString() &&
this.fileLocation === other.fileLocation &&
this.extraName === other.extraName &&
this.icon?.options?.iconUrl === other.icon?.options?.iconUrl &&
// @ts-ignore
this.icon?.options?.icon === other.icon?.options?.icon &&
// @ts-ignore
this.icon?.options?.iconColor === other.icon?.options?.iconColor &&
// @ts-ignore
this.icon?.options?.markerColor === other.icon?.options?.markerColor &&
// @ts-ignore
this.icon?.options?.shape === other.icon?.options?.shape
}
isSame(other: FileMarker) {
return (
this.file.name === other.file.name &&
this.location.toString() === other.location.toString() &&
this.fileLocation === other.fileLocation &&
this.extraName === other.extraName &&
this.icon?.options?.iconUrl === other.icon?.options?.iconUrl &&
// @ts-ignore
this.icon?.options?.icon === other.icon?.options?.icon &&
// @ts-ignore
this.icon?.options?.iconColor === other.icon?.options?.iconColor &&
// @ts-ignore
this.icon?.options?.markerColor ===
other.icon?.options?.markerColor &&
// @ts-ignore
this.icon?.options?.shape === other.icon?.options?.shape
);
}
generateId() : MarkerId {
return this.file.name + this.location.lat.toString() + this.location.lng.toString();
}
generateId(): MarkerId {
return (
this.file.name +
this.location.lat.toString() +
this.location.lng.toString()
);
}
}
export type MarkersMap = Map<MarkerId, FileMarker>;
@ -74,24 +81,38 @@ export type MarkersMap = Map<MarkerId, FileMarker>;
* @param app The Obsidian App instance
* @param skipMetadata If true will not find markers in the front matter
*/
export async function buildAndAppendFileMarkers(mapToAppendTo: FileMarker[], 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);
if (location) {
verifyLocation(location);
let leafletMarker = new FileMarker(file, location);
leafletMarker.icon = getIconForMarker(leafletMarker, settings, app);
mapToAppendTo.push(leafletMarker);
}
}
if ('locations' in frontMatter) {
const markersFromFile = await getMarkersFromFileContent(file, settings, app);
mapToAppendTo.push(...markersFromFile);
}
}
export async function buildAndAppendFileMarkers(
mapToAppendTo: FileMarker[],
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);
if (location) {
verifyLocation(location);
let leafletMarker = new FileMarker(file, location);
leafletMarker.icon = getIconForMarker(
leafletMarker,
settings,
app
);
mapToAppendTo.push(leafletMarker);
}
}
if ('locations' in frontMatter) {
const markersFromFile = await getMarkersFromFileContent(
file,
settings,
app
);
mapToAppendTo.push(...markersFromFile);
}
}
}
/**
@ -100,21 +121,23 @@ export async function buildAndAppendFileMarkers(mapToAppendTo: FileMarker[], fil
* @param settings The plugin settings
* @param app The Obsidian App instance
*/
export async function buildMarkers(files: TFile[], settings: PluginSettings, app: App): Promise<FileMarker[]> {
if (settings.debug)
console.time('buildMarkers');
let markers: FileMarker[] = [];
for (const file of files) {
await buildAndAppendFileMarkers(markers, file, settings, app);
}
if (settings.debug)
console.timeEnd('buildMarkers');
return markers;
export async function buildMarkers(
files: TFile[],
settings: PluginSettings,
app: App
): Promise<FileMarker[]> {
if (settings.debug) console.time('buildMarkers');
let markers: FileMarker[] = [];
for (const file of files) {
await buildAndAppendFileMarkers(markers, file, settings, app);
}
if (settings.debug) console.timeEnd('buildMarkers');
return markers;
}
function checkTagPatternMatch(tagPattern: string, tags: string[]) {
let match = wildcard(tagPattern, tags);
return match && match.length > 0;
let match = wildcard(tagPattern, tags);
return match && match.length > 0;
}
/**
@ -123,37 +146,42 @@ function checkTagPatternMatch(tagPattern: string, tags: string[]) {
* @param settings The plugin settings
* @param app The Obsidian App instance
*/
function getIconForMarker(marker: FileMarker, 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);
return getIconFromRules(tags, settings.markerIconRules);
function getIconForMarker(
marker: FileMarker,
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);
return getIconFromRules(tags, settings.markerIconRules);
}
export function getIconFromRules(tags: string[], rules: MarkerIconRule[]) {
// We iterate over the rules and apply them one by one, so later rules override earlier ones
let result = rules.find(item => item.ruleName === 'default').iconDetails;
for (const rule of rules) {
if (checkTagPatternMatch(rule.ruleName, tags)) {
result = Object.assign({}, result, rule.iconDetails);
}
}
return getIconFromOptions(result);
// We iterate over the rules and apply them one by one, so later rules override earlier ones
let result = rules.find((item) => item.ruleName === 'default').iconDetails;
for (const rule of rules) {
if (checkTagPatternMatch(rule.ruleName, tags)) {
result = Object.assign({}, result, rule.iconDetails);
}
}
return getIconFromOptions(result);
}
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;
try {
// @ts-ignore
L = localL;
return leaflet.ExtraMarkers.icon(iconSpec);
}
finally {
// @ts-ignore
L = backupL;
}
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;
try {
// @ts-ignore
L = localL;
return leaflet.ExtraMarkers.icon(iconSpec);
} finally {
// @ts-ignore
L = backupL;
}
}
/**
@ -162,10 +190,16 @@ export function getIconFromOptions(iconSpec: leaflet.BaseIconOptions) : leaflet.
* @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`);
if (location.lat < consts.LAT_LIMITS[0] || location.lat > consts.LAT_LIMITS[1])
throw Error(`Lat ${location.lat} is outside the allowed limits`);
if (
location.lng < consts.LNG_LIMITS[0] ||
location.lng > consts.LNG_LIMITS[1]
)
throw Error(`Lng ${location.lng} is outside the allowed limits`);
if (
location.lat < consts.LAT_LIMITS[0] ||
location.lat > consts.LAT_LIMITS[1]
)
throw Error(`Lat ${location.lat} is outside the allowed limits`);
}
/**
@ -173,13 +207,15 @@ export function verifyLocation(location: leaflet.LatLng) {
* @param content The file contents to find the coordinates in
*/
export function matchInlineLocation(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;
// 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));
// 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;
// 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));
}
/**
@ -188,76 +224,101 @@ export function matchInlineLocation(content: string): RegExpMatchArray[] {
* @param settings The plugin settings
* @param app The Obsidian App instance
*/
async function getMarkersFromFileContent(file: TFile, settings: PluginSettings, app: App): Promise<FileMarker[]> {
let markers: FileMarker[] = [];
const content = await app.vault.read(file);
const matches = matchInlineLocation(content);
for (const match of matches) {
try {
const location = new leaflet.LatLng(parseFloat(match[2]), parseFloat(match[3]));
verifyLocation(location);
const marker = new FileMarker(file, location);
if (match[1] && match[1].length > 0)
marker.extraName = match[1];
if (match[4]) {
// Parse the list of tags
const tagRegex = /tag:([\w\/\-]+)/g;
const tags = match[4].matchAll(tagRegex);
for (const tag of tags)
if (tag[1])
marker.tags.push('#' + tag[1]);
}
marker.fileLocation = match.index;
marker.fileLine = content.substring(0, marker.fileLocation).split('\n').length - 1;
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);
}
}
return markers;
async function getMarkersFromFileContent(
file: TFile,
settings: PluginSettings,
app: App
): Promise<FileMarker[]> {
let markers: FileMarker[] = [];
const content = await app.vault.read(file);
const matches = matchInlineLocation(content);
for (const match of matches) {
try {
const location = new leaflet.LatLng(
parseFloat(match[2]),
parseFloat(match[3])
);
verifyLocation(location);
const marker = new FileMarker(file, location);
if (match[1] && match[1].length > 0) marker.extraName = match[1];
if (match[4]) {
// Parse the list of tags
const tagRegex = /tag:([\w\/\-]+)/g;
const tags = match[4].matchAll(tagRegex);
for (const tag of tags)
if (tag[1]) marker.tags.push('#' + tag[1]);
}
marker.fileLocation = match.index;
marker.fileLine =
content.substring(0, marker.fileLocation).split('\n').length -
1;
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
);
}
}
return markers;
}
async function makeTextSnippet(file: TFile, fileContent: string, fileLocation: number, settings: PluginSettings) {
let snippet = '';
if (settings.snippetLines && settings.snippetLines > 0) {
// We subtract 1 because the central (location) line will always be displayed
let linesAbove = Math.round((settings.snippetLines - 1) / 2);
let linesBelow = settings.snippetLines - 1 - linesAbove;
// Start from the beginning of the line on which the location was found, then go back
let snippetStart = fileContent.lastIndexOf('\n', fileLocation);
while (linesAbove > 0 && snippetStart > -1) {
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)
break;
snippetStart = prevLine;
linesAbove -= 1;
}
// Either if we reached the beginning of the file (-1) or if we stopped due to a newline, we want a step forward
snippetStart += 1;
// Always include the line with the location
let snippetEnd = fileContent.indexOf('\n', fileLocation);
// Now continue forward
while (linesBelow > 0 && snippetEnd > -1) {
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)
break;
snippetEnd = nextLine;
linesBelow -= 1;
}
if (snippetEnd === -1)
snippetEnd = fileContent.length;
snippet = fileContent.substring(snippetStart, snippetEnd);
snippet = snippet.replace(/\`location:.*\`/g, '<span class="map-view-location">`location:...`</span>');
snippet = snippet.replace(/(\[.*\])\(.+\)/g, '<span class="map-view-location">$1(geo:...)</span>');
}
return snippet;
async function makeTextSnippet(
file: TFile,
fileContent: string,
fileLocation: number,
settings: PluginSettings
) {
let snippet = '';
if (settings.snippetLines && settings.snippetLines > 0) {
// We subtract 1 because the central (location) line will always be displayed
let linesAbove = Math.round((settings.snippetLines - 1) / 2);
let linesBelow = settings.snippetLines - 1 - linesAbove;
// Start from the beginning of the line on which the location was found, then go back
let snippetStart = fileContent.lastIndexOf('\n', fileLocation);
while (linesAbove > 0 && snippetStart > -1) {
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) break;
snippetStart = prevLine;
linesAbove -= 1;
}
// Either if we reached the beginning of the file (-1) or if we stopped due to a newline, we want a step forward
snippetStart += 1;
// Always include the line with the location
let snippetEnd = fileContent.indexOf('\n', fileLocation);
// Now continue forward
while (linesBelow > 0 && snippetEnd > -1) {
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) break;
snippetEnd = nextLine;
linesBelow -= 1;
}
if (snippetEnd === -1) snippetEnd = fileContent.length;
snippet = fileContent.substring(snippetStart, snippetEnd);
snippet = snippet.replace(
/\`location:.*\`/g,
'<span class="map-view-location">`location:...`</span>'
);
snippet = snippet.replace(
/(\[.*\])\(.+\)/g,
'<span class="map-view-location">$1(geo:...)</span>'
);
}
return snippet;
}
/**
@ -265,24 +326,28 @@ async function makeTextSnippet(file: TFile, fileContent: string, fileLocation: n
* @param file The file to load the front matter from
* @param app The Obsidian App instance
*/
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') {
const location = new leaflet.LatLng(frontMatter.location[0], frontMatter.location[1]);
verifyLocation(location);
return location;
}
else
console.log(`Unknown: `, location);
}
catch (e) {
console.log(`Error converting location in file ${file.name}:`, e);
}
}
return null;
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'
) {
const location = new leaflet.LatLng(
frontMatter.location[0],
frontMatter.location[1]
);
verifyLocation(location);
return location;
} else console.log(`Unknown: `, location);
} catch (e) {
console.log(`Error converting location in file ${file.name}:`, e);
}
}
return null;
}

View file

@ -1,4 +1,11 @@
import { Editor, MarkdownView, App, WorkspaceLeaf, SuggestModal, TFile } from 'obsidian';
import {
Editor,
MarkdownView,
App,
WorkspaceLeaf,
SuggestModal,
TFile,
} from 'obsidian';
import * as leaflet from 'leaflet';
import { PluginSettings } from 'src/settings';
@ -9,115 +16,139 @@ import * as utils from 'src/utils';
import * as consts from 'src/consts';
class SuggestInfo {
name: string;
location?: leaflet.LatLng;
type: 'searchResult' | 'url';
name: string;
location?: leaflet.LatLng;
type: 'searchResult' | 'url';
}
export class NewNoteDialog extends SuggestModal<SuggestInfo> {
private settings: PluginSettings;
private suggestor: LocationSuggest;
private urlConvertor: UrlConvertor;
private lastSearchTime = 0;
private delayInMs = 250;
private lastSearch = '';
private lastSearchResults: SuggestInfo[] = [];
private settings: PluginSettings;
private suggestor: LocationSuggest;
private urlConvertor: UrlConvertor;
private lastSearchTime = 0;
private delayInMs = 250;
private lastSearch = '';
private lastSearchResults: SuggestInfo[] = [];
private dialogAction: 'newNote' | 'addToNote' = 'newNote';
private editor: Editor = null;
private dialogAction: 'newNote' | 'addToNote' = 'newNote';
private editor: Editor = null;
constructor(app: App, settings: PluginSettings, dialogAction: 'newNote' | 'addToNote' = 'newNote', editor: Editor = null) {
super(app);
this.settings = settings;
this.suggestor = new LocationSuggest(this.app, this.settings);
this.urlConvertor = new UrlConvertor(this.app, this.settings);
this.dialogAction = dialogAction;
this.editor = editor;
constructor(
app: App,
settings: PluginSettings,
dialogAction: 'newNote' | 'addToNote' = 'newNote',
editor: Editor = null
) {
super(app);
this.settings = settings;
this.suggestor = new LocationSuggest(this.app, this.settings);
this.urlConvertor = new UrlConvertor(this.app, this.settings);
this.dialogAction = dialogAction;
this.editor = editor;
this.setPlaceholder('Type a search query or paste a supported URL');
this.setInstructions([{command: 'enter', purpose: 'to use'}]);
}
this.setPlaceholder('Type a search query or paste a supported URL');
this.setInstructions([{ command: 'enter', purpose: 'to use' }]);
}
getSuggestions(query: string) {
let result: SuggestInfo[] = [];
const urlResult = this.parseLocationAsUrl(query);
if (urlResult)
result.push(urlResult);
if (query == this.lastSearch) {
result = result.concat(this.lastSearchResults);
}
this.getSearchResultsWithDelay(query);
return result;
}
getSuggestions(query: string) {
let result: SuggestInfo[] = [];
const urlResult = this.parseLocationAsUrl(query);
if (urlResult) result.push(urlResult);
if (query == this.lastSearch) {
result = result.concat(this.lastSearchResults);
}
this.getSearchResultsWithDelay(query);
return result;
}
renderSuggestion(value: SuggestInfo, el: HTMLElement) {
el.setText(value.name);
}
renderSuggestion(value: SuggestInfo, el: HTMLElement) {
el.setText(value.name);
}
onChooseSuggestion(value: SuggestInfo, evt: MouseEvent | KeyboardEvent) {
if (this.dialogAction == 'newNote')
this.newNote(value.location, evt, value.name);
else if (this.dialogAction == 'addToNote')
this.addToNote(value.location, evt, value.name);
}
onChooseSuggestion(value: SuggestInfo, evt: MouseEvent | KeyboardEvent) {
if (this.dialogAction == 'newNote')
this.newNote(value.location, evt, value.name);
else if (this.dialogAction == 'addToNote')
this.addToNote(value.location, evt, value.name);
}
async newNote(location: leaflet.LatLng, ev: MouseEvent | KeyboardEvent, query: string) {
const locationString = `${location.lat},${location.lng}`;
const newFileName = utils.formatWithTemplates(this.settings.newNoteNameFormat, query);
const file: TFile = await utils.newNote(this.app, 'singleLocation', this.settings.newNotePath,
newFileName, locationString, this.settings.newNoteTemplate);
// If there is an open map view, use it to decide how and where to open the file.
// Otherwise, open the file from the active leaf
const mapView = utils.findOpenMapView(this.app);
if (mapView) {
mapView.goToFile(file, ev.ctrlKey, utils.handleNewNoteCursorMarker);
}
else {
const leaf = this.app.workspace.activeLeaf;
await leaf.openFile(file);
const editor = await utils.getEditor(this.app);
if (editor)
await utils.handleNewNoteCursorMarker(editor);
}
}
async newNote(
location: leaflet.LatLng,
ev: MouseEvent | KeyboardEvent,
query: string
) {
const locationString = `${location.lat},${location.lng}`;
const newFileName = utils.formatWithTemplates(
this.settings.newNoteNameFormat,
query
);
const file: TFile = await utils.newNote(
this.app,
'singleLocation',
this.settings.newNotePath,
newFileName,
locationString,
this.settings.newNoteTemplate
);
// If there is an open map view, use it to decide how and where to open the file.
// Otherwise, open the file from the active leaf
const mapView = utils.findOpenMapView(this.app);
if (mapView) {
mapView.goToFile(file, ev.ctrlKey, utils.handleNewNoteCursorMarker);
} else {
const leaf = this.app.workspace.activeLeaf;
await leaf.openFile(file);
const editor = await utils.getEditor(this.app);
if (editor) await utils.handleNewNoteCursorMarker(editor);
}
}
async addToNote(location: leaflet.LatLng, ev: MouseEvent | KeyboardEvent, query: string) {
const locationString = `[${location.lat},${location.lng}]`;
utils.verifyOrAddFrontMatter(this.editor, 'location', locationString);
}
async addToNote(
location: leaflet.LatLng,
ev: MouseEvent | KeyboardEvent,
query: string
) {
const locationString = `[${location.lat},${location.lng}]`;
utils.verifyOrAddFrontMatter(this.editor, 'location', locationString);
}
async getSearchResultsWithDelay(query: string) {
// TODO merge this with LocationSuggest
if (query === this.lastSearch || query.length < 3)
return;
const timestamp = Date.now();
this.lastSearchTime = timestamp;
const Sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
await Sleep(this.delayInMs);
if (this.lastSearchTime != timestamp) {
// Search is canceled by a newer search
return null;
}
// After the sleep our search is still the last -- so the user stopped and we can go on
const results = await this.suggestor.searchProvider.search({query: query});
const suggestions = results.map(result => ({
name: result.label,
location: new leaflet.LatLng(result.y, result.x),
type: 'searchResult'
} as SuggestInfo));
this.lastSearchResults = suggestions;
this.lastSearch = query;
(this as any).updateSuggestions();
return suggestions;
}
async getSearchResultsWithDelay(query: string) {
// TODO merge this with LocationSuggest
if (query === this.lastSearch || query.length < 3) return;
const timestamp = Date.now();
this.lastSearchTime = timestamp;
const Sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
await Sleep(this.delayInMs);
if (this.lastSearchTime != timestamp) {
// Search is canceled by a newer search
return null;
}
// After the sleep our search is still the last -- so the user stopped and we can go on
const results = await this.suggestor.searchProvider.search({
query: query,
});
const suggestions = results.map(
(result) =>
({
name: result.label,
location: new leaflet.LatLng(result.y, result.x),
type: 'searchResult',
} as SuggestInfo)
);
this.lastSearchResults = suggestions;
this.lastSearch = query;
(this as any).updateSuggestions();
return suggestions;
}
parseLocationAsUrl(query: string): SuggestInfo {
const result = this.urlConvertor.parseLocationFromUrl(query);
if (result)
return {
name: `Parsed from ${result.ruleName}: ${result.location.lat}, ${result.location.lng}`,
location: result.location,
type: 'url'
};
}
parseLocationAsUrl(query: string): SuggestInfo {
const result = this.urlConvertor.parseLocationFromUrl(query);
if (result)
return {
name: `Parsed from ${result.ruleName}: ${result.location.lat}, ${result.location.lng}`,
location: result.location,
type: 'url',
};
}
}

View file

@ -1,82 +1,90 @@
import { Modal, App, TextComponent, ButtonComponent, ToggleComponent } from 'obsidian';
import {
Modal,
App,
TextComponent,
ButtonComponent,
ToggleComponent,
} from 'obsidian';
import { PluginSettings, MapState } from 'src/settings';
import MapViewPlugin from 'src/main';
export class NewPresetDialog extends Modal {
private plugin: MapViewPlugin;
private settings: PluginSettings;
private stateToSave: MapState;
private callback: (index: string) => void;
private plugin: MapViewPlugin;
private settings: PluginSettings;
private stateToSave: MapState;
private callback: (index: string) => void;
constructor(app: App, stateToSave: MapState, plugin: MapViewPlugin,
settings: PluginSettings, callback: (index: string) => void) {
super(app);
this.plugin = plugin;
this.settings = settings;
this.stateToSave = stateToSave;
this.callback = callback;
}
constructor(
app: App,
stateToSave: MapState,
plugin: MapViewPlugin,
settings: PluginSettings,
callback: (index: string) => void
) {
super(app);
this.plugin = plugin;
this.settings = settings;
this.stateToSave = stateToSave;
this.callback = callback;
}
onOpen() {
let statusLabel: HTMLDivElement = null;
const grid = this.contentEl.createDiv({cls: 'newPresetDialogGrid'});
const row1 = grid.createDiv({cls: 'newPresetDialogLine'});
const row2 = grid.createDiv({cls: 'newPresetDialogLine'});
const row3 = grid.createDiv({cls: 'newPresetDialogLine'});
let name = new TextComponent(row1)
.onChange(value => {
if (value == 'Default' || value.length === 0)
saveButton.disabled = true;
else
saveButton.disabled = false;
if (this.findPresetByName(value))
statusLabel.setText("Clicking 'Save' will overwrite an existing preset.");
else
statusLabel.setText('');
});
name.inputEl.style.width = '100%';
name.inputEl.addEventListener('keypress', (ev: KeyboardEvent) => {
if (ev.key == 'Enter')
saveButton.buttonEl.click();
});
const includeMapSource = row2.createEl('input', { type: 'checkbox' });
includeMapSource.id = 'includeMapSource';
const includeMapSourceLabel = row2.createEl('label');
includeMapSourceLabel.setAttribute('for', 'includeMapSource');
includeMapSourceLabel.textContent = 'Include chosen map source';
let saveButton = new ButtonComponent(row3)
.setButtonText('Save')
.onClick(async () => {
let existingPreset = this.findPresetByName(name.getValue());
let newState = {...this.stateToSave, name: name.getValue()};
if (!(includeMapSource as HTMLInputElement).checked)
newState.chosenMapSource = undefined;
if (existingPreset) {
Object.assign(existingPreset, newState);
newState = existingPreset;
}
else {
this.settings.savedStates.push(newState);
}
await this.plugin.saveSettings();
// Update the presets list
const presetIndex = this.settings.savedStates.indexOf(newState);
// Select the new preset in the view's controls
this.callback((presetIndex + 1).toString());
this.close();
});
new ButtonComponent(row3)
.setButtonText('Cancel')
.onClick(() => {
this.close();
});
statusLabel = row3.createDiv();
name.onChanged();
name.inputEl.focus();
}
onOpen() {
let statusLabel: HTMLDivElement = null;
const grid = this.contentEl.createDiv({ cls: 'newPresetDialogGrid' });
const row1 = grid.createDiv({ cls: 'newPresetDialogLine' });
const row2 = grid.createDiv({ cls: 'newPresetDialogLine' });
const row3 = grid.createDiv({ cls: 'newPresetDialogLine' });
let name = new TextComponent(row1).onChange((value) => {
if (value == 'Default' || value.length === 0)
saveButton.disabled = true;
else saveButton.disabled = false;
if (this.findPresetByName(value))
statusLabel.setText(
"Clicking 'Save' will overwrite an existing preset."
);
else statusLabel.setText('');
});
name.inputEl.style.width = '100%';
name.inputEl.addEventListener('keypress', (ev: KeyboardEvent) => {
if (ev.key == 'Enter') saveButton.buttonEl.click();
});
const includeMapSource = row2.createEl('input', { type: 'checkbox' });
includeMapSource.id = 'includeMapSource';
const includeMapSourceLabel = row2.createEl('label');
includeMapSourceLabel.setAttribute('for', 'includeMapSource');
includeMapSourceLabel.textContent = 'Include chosen map source';
let saveButton = new ButtonComponent(row3)
.setButtonText('Save')
.onClick(async () => {
let existingPreset = this.findPresetByName(name.getValue());
let newState = { ...this.stateToSave, name: name.getValue() };
if (!(includeMapSource as HTMLInputElement).checked)
newState.chosenMapSource = undefined;
if (existingPreset) {
Object.assign(existingPreset, newState);
newState = existingPreset;
} else {
this.settings.savedStates.push(newState);
}
await this.plugin.saveSettings();
// Update the presets list
const presetIndex = this.settings.savedStates.indexOf(newState);
// Select the new preset in the view's controls
this.callback((presetIndex + 1).toString());
this.close();
});
new ButtonComponent(row3).setButtonText('Cancel').onClick(() => {
this.close();
});
statusLabel = row3.createDiv();
name.onChanged();
name.inputEl.focus();
}
findPresetByName(name: string) {
return this.settings.savedStates?.find(preset => preset.name === name);
}
findPresetByName(name: string) {
return this.settings.savedStates?.find(
(preset) => preset.name === name
);
}
}

View file

@ -3,199 +3,283 @@ import { LatLng } from 'leaflet';
import { SplitDirection } from 'obsidian';
export type PluginSettings = {
defaultState: MapState;
savedStates: MapState[];
// Deprecated
markerIcons?: Record<string, any>;
markerIconRules?: MarkerIconRule[];
zoomOnGoFromNote: number;
// Deprecated
tilesUrl?: string;
// Deprecated
chosenMapSource?: number;
mapSources: TileSource[];
chosenMapMode?: MapLightDark;
// Deprecated
defaultMapCenter?: LatLng;
// Deprecated
defaultZoom?: number;
// Deprecated
defaultTags?: string[];
autoZoom: boolean;
markerClickBehavior?: 'samePane' | 'secondPane' | 'alwaysNew';
newPaneSplitDirection?: SplitDirection;
newNoteNameFormat?: string;
newNotePath?: string;
newNoteTemplate?: string;
// Deprecated
snippetLines?: number;
showNotePreview?: boolean;
showClusterPreview?: boolean,
debug?: boolean;
openIn?: OpenInSettings[];
urlParsingRules?: UrlParsingRule[];
mapControls?: MapControls;
maxClusterRadiusPixels: number;
searchProvider?: 'osm' | 'google';
geocodingApiKey?: string;
saveHistory?: boolean;
}
defaultState: MapState;
savedStates: MapState[];
// Deprecated
markerIcons?: Record<string, any>;
markerIconRules?: MarkerIconRule[];
zoomOnGoFromNote: number;
// Deprecated
tilesUrl?: string;
// Deprecated
chosenMapSource?: number;
mapSources: TileSource[];
chosenMapMode?: MapLightDark;
// Deprecated
defaultMapCenter?: LatLng;
// Deprecated
defaultZoom?: number;
// Deprecated
defaultTags?: string[];
autoZoom: boolean;
markerClickBehavior?: 'samePane' | 'secondPane' | 'alwaysNew';
newPaneSplitDirection?: SplitDirection;
newNoteNameFormat?: string;
newNotePath?: string;
newNoteTemplate?: string;
// Deprecated
snippetLines?: number;
showNotePreview?: boolean;
showClusterPreview?: boolean;
debug?: boolean;
openIn?: OpenInSettings[];
urlParsingRules?: UrlParsingRule[];
mapControls?: MapControls;
maxClusterRadiusPixels: number;
searchProvider?: 'osm' | 'google';
geocodingApiKey?: string;
saveHistory?: boolean;
};
/** Represents a logical state of the map, in separation from the map display */
export type MapState = {
name: string;
mapZoom: number;
mapCenter: LatLng;
/** The tags that the user specified (including the # character) */
tags: string[];
chosenMapSource?: number;
forceHistorySave?: boolean;
}
name: string;
mapZoom: number;
mapCenter: LatLng;
/** The tags that the user specified (including the # character) */
tags: string[];
chosenMapSource?: number;
forceHistorySave?: boolean;
};
export function mergeStates(state1: MapState, state2: MapState): MapState {
// Overwrite an existing state with a new one, that may have null or partial values which need to be ignored
// and taken from the existing state
const clearedState = Object.fromEntries(Object.entries(state2).filter(([_, value]) => value != null));
return {...state1, ...clearedState};
// Overwrite an existing state with a new one, that may have null or partial values which need to be ignored
// and taken from the existing state
const clearedState = Object.fromEntries(
Object.entries(state2).filter(([_, value]) => value != null)
);
return { ...state1, ...clearedState };
}
const xor = (a: any, b: any) => (a && !b) || (!a && b);
export function areStatesEqual(state1: MapState, state2: MapState) {
if (!state1 || !state2)
return false;
if (xor(state1.mapCenter, state2.mapCenter))
return false;
if (state1.mapCenter) {
// To compare locations we need to construct an actual LatLng object because state1 may just
// be a simple dict and not an actual LatLng
const mapCenter1 = new LatLng(state1.mapCenter.lat, state1.mapCenter.lng);
const mapCenter2 = new LatLng(state2.mapCenter.lat, state2.mapCenter.lng);
if (mapCenter1.distanceTo(mapCenter2) > 1000)
return false;
}
return JSON.stringify(state1.tags) == JSON.stringify(state2.tags) &&
state2.mapZoom == state2.mapZoom &&
state1.chosenMapSource == state2.chosenMapSource;
if (!state1 || !state2) return false;
if (xor(state1.mapCenter, state2.mapCenter)) return false;
if (state1.mapCenter) {
// To compare locations we need to construct an actual LatLng object because state1 may just
// be a simple dict and not an actual LatLng
const mapCenter1 = new LatLng(
state1.mapCenter.lat,
state1.mapCenter.lng
);
const mapCenter2 = new LatLng(
state2.mapCenter.lat,
state2.mapCenter.lng
);
if (mapCenter1.distanceTo(mapCenter2) > 1000) return false;
}
return (
JSON.stringify(state1.tags) == JSON.stringify(state2.tags) &&
state2.mapZoom == state2.mapZoom &&
state1.chosenMapSource == state2.chosenMapSource
);
}
export type MapLightDark = 'auto' | 'light' | 'dark';
export type TileSource = {
name: string;
urlLight: string;
urlDark?: string;
currentMode?: MapLightDark;
preset?: boolean;
ignoreErrors?: boolean;
}
name: string;
urlLight: string;
urlDark?: string;
currentMode?: MapLightDark;
preset?: boolean;
ignoreErrors?: boolean;
};
export type OpenInSettings = {
name: string;
urlPattern: string;
}
name: string;
urlPattern: string;
};
export type UrlParsingRule = {
name: string;
regExp: string;
order: 'latFirst' | 'lngFirst';
preset: boolean;
}
name: string;
regExp: string;
order: 'latFirst' | 'lngFirst';
preset: boolean;
};
export type MapControls = {
filtersDisplayed: boolean;
viewDisplayed: boolean;
presetsDisplayed: boolean;
}
filtersDisplayed: boolean;
viewDisplayed: boolean;
presetsDisplayed: boolean;
};
export type MarkerIconRule = {
ruleName: string;
preset: boolean;
iconDetails: any;
}
ruleName: string;
preset: boolean;
iconDetails: any;
};
export const DEFAULT_SETTINGS: PluginSettings = {
defaultState: {
name: 'Default',
mapZoom: 1.0,
mapCenter: new LatLng(40.44694705960048 ,-180.70312500000003),
tags: [],
chosenMapSource: 0
},
savedStates: [],
markerIconRules: [
{ruleName: "default", preset: true, iconDetails: {"prefix": "fas", "icon": "fa-circle", "markerColor": "blue"}},
{ruleName: "#trip", preset: false, iconDetails: {"prefix": "fas", "icon": "fa-hiking", "markerColor": "green"}},
{ruleName: "#trip-water", preset: false, iconDetails: {"prefix": "fas", "markerColor": "blue"}},
{ruleName: "#dogs", preset: false, iconDetails: {"prefix": "fas", "icon": "fa-paw"}},
],
zoomOnGoFromNote: 15,
autoZoom: true,
markerClickBehavior: 'samePane',
newNoteNameFormat: 'Location added on {{date:YYYY-MM-DD}}T{{date:HH-mm}}',
showNotePreview: true,
showClusterPreview: false,
debug: false,
openIn: [{name: 'Google Maps', urlPattern: 'https://maps.google.com/?q={x},{y}'}],
urlParsingRules: [
{name: 'OpenStreetMap Show Address', regExp: /https:\/\/www.openstreetmap.org\S*query=([0-9\.\-]+%2C[0-9\.\-]+)\S*/.source, order: 'latFirst', preset: true},
{name: 'Generic Lat,Lng', regExp: /([0-9\.\-]+), ([0-9\.\-]+)/.source, order: 'latFirst', preset: true}
],
mapControls: {filtersDisplayed: true, viewDisplayed: true, presetsDisplayed: false},
maxClusterRadiusPixels: 20,
searchProvider: 'osm',
mapSources: [{name: 'CartoDB', urlLight: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png', preset: true}],
// mapSources: [{name: 'OpenStreetMap', urlLight: consts.TILES_URL_OPENSTREETMAP}],
chosenMapMode: 'auto',
saveHistory: true
defaultState: {
name: 'Default',
mapZoom: 1.0,
mapCenter: new LatLng(40.44694705960048, -180.70312500000003),
tags: [],
chosenMapSource: 0,
},
savedStates: [],
markerIconRules: [
{
ruleName: 'default',
preset: true,
iconDetails: {
prefix: 'fas',
icon: 'fa-circle',
markerColor: 'blue',
},
},
{
ruleName: '#trip',
preset: false,
iconDetails: {
prefix: 'fas',
icon: 'fa-hiking',
markerColor: 'green',
},
},
{
ruleName: '#trip-water',
preset: false,
iconDetails: { prefix: 'fas', markerColor: 'blue' },
},
{
ruleName: '#dogs',
preset: false,
iconDetails: { prefix: 'fas', icon: 'fa-paw' },
},
],
zoomOnGoFromNote: 15,
autoZoom: true,
markerClickBehavior: 'samePane',
newNoteNameFormat: 'Location added on {{date:YYYY-MM-DD}}T{{date:HH-mm}}',
showNotePreview: true,
showClusterPreview: false,
debug: false,
openIn: [
{
name: 'Google Maps',
urlPattern: 'https://maps.google.com/?q={x},{y}',
},
],
urlParsingRules: [
{
name: 'OpenStreetMap Show Address',
regExp: /https:\/\/www.openstreetmap.org\S*query=([0-9\.\-]+%2C[0-9\.\-]+)\S*/
.source,
order: 'latFirst',
preset: true,
},
{
name: 'Generic Lat,Lng',
regExp: /([0-9\.\-]+), ([0-9\.\-]+)/.source,
order: 'latFirst',
preset: true,
},
],
mapControls: {
filtersDisplayed: true,
viewDisplayed: true,
presetsDisplayed: false,
},
maxClusterRadiusPixels: 20,
searchProvider: 'osm',
mapSources: [
{
name: 'CartoDB',
urlLight:
'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png',
preset: true,
},
],
// mapSources: [{name: 'OpenStreetMap', urlLight: consts.TILES_URL_OPENSTREETMAP}],
chosenMapMode: 'auto',
saveHistory: true,
};
export function convertLegacyMarkerIcons(settings: PluginSettings): boolean {
if (settings.markerIcons) {
settings.markerIconRules = [];
for (let key in settings.markerIcons) {
const newRule: MarkerIconRule = {ruleName: key, preset: key === 'default', iconDetails: settings.markerIcons[key]};
settings.markerIconRules.push(newRule);
}
settings.markerIcons = null;
return true;
}
return false;
if (settings.markerIcons) {
settings.markerIconRules = [];
for (let key in settings.markerIcons) {
const newRule: MarkerIconRule = {
ruleName: key,
preset: key === 'default',
iconDetails: settings.markerIcons[key],
};
settings.markerIconRules.push(newRule);
}
settings.markerIcons = null;
return true;
}
return false;
}
export function convertLegacyTilesUrl(settings: PluginSettings): boolean {
if (settings.tilesUrl) {
settings.mapSources = [{name: 'Default', urlLight: settings.tilesUrl}];
settings.tilesUrl = null;
return true;
}
return false;
if (settings.tilesUrl) {
settings.mapSources = [
{ name: 'Default', urlLight: settings.tilesUrl },
];
settings.tilesUrl = null;
return true;
}
return false;
}
export function convertLegacyDefaultState(settings: PluginSettings): boolean {
if (settings.defaultTags || settings.defaultZoom || settings.defaultMapCenter || settings.chosenMapSource) {
settings.defaultState = {
name: 'Default',
mapZoom: settings.defaultZoom || DEFAULT_SETTINGS.defaultState.mapZoom,
mapCenter: settings.defaultMapCenter || DEFAULT_SETTINGS.defaultState.mapCenter,
tags: settings.defaultTags || DEFAULT_SETTINGS.defaultState.tags,
chosenMapSource: settings.chosenMapSource ?? DEFAULT_SETTINGS.defaultState.chosenMapSource
};
settings.defaultTags = settings.defaultZoom = settings.defaultMapCenter = settings.chosenMapSource = null;
return true;
}
return false;
if (
settings.defaultTags ||
settings.defaultZoom ||
settings.defaultMapCenter ||
settings.chosenMapSource
) {
settings.defaultState = {
name: 'Default',
mapZoom:
settings.defaultZoom || DEFAULT_SETTINGS.defaultState.mapZoom,
mapCenter:
settings.defaultMapCenter ||
DEFAULT_SETTINGS.defaultState.mapCenter,
tags: settings.defaultTags || DEFAULT_SETTINGS.defaultState.tags,
chosenMapSource:
settings.chosenMapSource ??
DEFAULT_SETTINGS.defaultState.chosenMapSource,
};
settings.defaultTags =
settings.defaultZoom =
settings.defaultMapCenter =
settings.chosenMapSource =
null;
return true;
}
return false;
}
export function removeLegacyPresets1(settings: PluginSettings): boolean {
const googleMapsParsingRule = settings.urlParsingRules.findIndex(rule => rule.name == 'Google Maps' && rule.preset );
if (googleMapsParsingRule > -1) {
settings.urlParsingRules.splice(googleMapsParsingRule, 1);
return true;
}
if (settings.mapSources.findIndex(item => item.name == DEFAULT_SETTINGS.mapSources[0].name) === -1) {
settings.mapSources.unshift(DEFAULT_SETTINGS.mapSources[0]);
return true;
}
return false;
const googleMapsParsingRule = settings.urlParsingRules.findIndex(
(rule) => rule.name == 'Google Maps' && rule.preset
);
if (googleMapsParsingRule > -1) {
settings.urlParsingRules.splice(googleMapsParsingRule, 1);
return true;
}
if (
settings.mapSources.findIndex(
(item) => item.name == DEFAULT_SETTINGS.mapSources[0].name
) === -1
) {
settings.mapSources.unshift(DEFAULT_SETTINGS.mapSources[0]);
return true;
}
return false;
}

File diff suppressed because it is too large Load diff

View file

@ -6,76 +6,89 @@ import * as utils from 'src/utils';
/** A class to convert a string (usually a URL) into geolocation format */
export class UrlConvertor {
private settings: PluginSettings;
private settings: PluginSettings;
constructor(app: App, settings: PluginSettings) {
this.settings = settings;
}
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
*/
findMatchInLine(editor: Editor): leaflet.LatLng | null {
const cursor = editor.getCursor();
const result = this.parseLocationFromUrl(editor.getLine(cursor.line));
return result?.location;
}
/**
* 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
*/
findMatchInLine(editor: Editor): leaflet.LatLng | null {
const cursor = editor.getCursor();
const result = this.parseLocationFromUrl(editor.getLine(cursor.line));
return result?.location;
}
/**
* Get geolocation from an encoded string (usually a URL).
* Will try each url parsing rule until one succeeds.
* @param line The string to decode
*/
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;
}
/**
* Get geolocation from an encoded string (usually a URL).
* Will try each url parsing rule until one succeeds.
* @param line The string to decode
*/
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;
}
/**
* 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', '');
}
/**
* 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
*/
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);
}
/**
* Replace the text at the cursor location with a geo link
* @param editor The Obsidian Editor instance
*/
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
);
}
}

View file

@ -1,4 +1,12 @@
import { WorkspaceLeaf, MarkdownView, Editor, App, TFile, Menu, MenuItem } from 'obsidian';
import {
WorkspaceLeaf,
MarkdownView,
Editor,
App,
TFile,
Menu,
MenuItem,
} from 'obsidian';
import * as moment_ from 'moment';
import * as leaflet from 'leaflet';
@ -8,14 +16,16 @@ import * as consts from './consts';
import { MapView } from './mapView';
export function formatWithTemplates(s: string, query = '') {
const datePattern = /{{date:([a-zA-Z\-\/\.\:]*)}}/g;
const queryPattern = /{{query}}/g;
const replaced = s.replace(datePattern, (_, pattern) => {
// @ts-ignore
return moment().format(pattern);
}).replace(queryPattern, query);
return replaced;
const datePattern = /{{date:([a-zA-Z\-\/\.\:]*)}}/g;
const queryPattern = /{{query}}/g;
const replaced = s
.replace(datePattern, (_, pattern) => {
// @ts-ignore
return moment().format(pattern);
})
.replace(queryPattern, query);
return replaced;
}
type NewNoteType = 'singleLocation' | 'multiLocation';
@ -23,8 +33,8 @@ type NewNoteType = 'singleLocation' | 'multiLocation';
const CURSOR = '$CURSOR$';
function sanitizeFileName(s: string) {
const illegalChars = /[\?<>\\:\*\|":]/g;
return s.replace(illegalChars, '-');
const illegalChars = /[\?<>\\:\*\|":]/g;
return s.replace(illegalChars, '-');
}
/**
@ -36,25 +46,30 @@ function sanitizeFileName(s: string) {
* @param location The geolocation
* @param templatePath Optional path to a template to use for constructing the new file
*/
export async function newNote(app: App, newNoteType: NewNoteType, directory: string, fileName: string,
location: string, templatePath?: string): Promise<TFile>
{
// `$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 = '';
if (templatePath)
templateContent = await app.vault.adapter.read(templatePath);
let fullName = sanitizeFileName(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);
}
catch (e) {
throw Error(`Cannot create file named ${fullName}: ${e}`);
}
export async function newNote(
app: App,
newNoteType: NewNoteType,
directory: string,
fileName: string,
location: string,
templatePath?: string
): Promise<TFile> {
// `$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 = '';
if (templatePath)
templateContent = await app.vault.adapter.read(templatePath);
let fullName = sanitizeFileName(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);
} catch (e) {
throw Error(`Cannot create file named ${fullName}: ${e}`);
}
}
/**
@ -63,52 +78,69 @@ export async function newNote(app: App, newNoteType: NewNoteType, directory: str
* @param fileLocation The character index in the file to go to
* @param highlight If true will select the whole line
*/
export async function goToEditorLocation(editor: Editor, fileLocation: number, highlight: boolean) {
if (fileLocation) {
let pos = editor.offsetToPos(fileLocation);
if (highlight) {
const lineContent = editor.getLine(pos.line);
editor.setSelection({ch: 0, line: pos.line}, {ch: lineContent.length, line: pos.line});
} else {
editor.setCursor(pos);
editor.refresh();
}
}
editor.focus();
export async function goToEditorLocation(
editor: Editor,
fileLocation: number,
highlight: boolean
) {
if (fileLocation) {
let pos = editor.offsetToPos(fileLocation);
if (highlight) {
const lineContent = editor.getLine(pos.line);
editor.setSelection(
{ ch: 0, line: pos.line },
{ ch: lineContent.length, line: pos.line }
);
} else {
editor.setCursor(pos);
editor.refresh();
}
}
editor.focus();
}
export async function handleNewNoteCursorMarker(editor: Editor) {
const templateValue = editor.getValue();
const cursorMarkerIndex = templateValue.indexOf(CURSOR);
if (cursorMarkerIndex > -1) {
editor.setValue(templateValue.replace(CURSOR, ''));
await goToEditorLocation(editor, cursorMarkerIndex, false);
}
const templateValue = editor.getValue();
const cursorMarkerIndex = templateValue.indexOf(CURSOR);
if (cursorMarkerIndex > -1) {
editor.setValue(templateValue.replace(CURSOR, ''));
await goToEditorLocation(editor, cursorMarkerIndex, false);
}
}
// 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 {
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;
}
return false;
export function verifyOrAddFrontMatter(
editor: Editor,
fieldName: string,
fieldValue: string
): boolean {
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;
}
return false;
}
/**
@ -117,31 +149,38 @@ export function verifyOrAddFrontMatter(editor: Editor, fieldName: string, fieldV
* @param location The geolocation to use in the menu item
* @param settings Plugin settings
*/
export function populateOpenInItems(menu: Menu, location: 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());
menu.addItem((item: MenuItem) => {
item.setTitle(`Open in ${setting.name}`);
item.onClick(_ev => {
open(fullUrl);
});
})
}
export function populateOpenInItems(
menu: Menu,
location: 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());
menu.addItem((item: MenuItem) => {
item.setTitle(`Open in ${setting.name}`);
item.onClick((_ev) => {
open(fullUrl);
});
});
}
}
export function findOpenMapView(app: App) {
const maps = app.workspace.getLeavesOfType(consts.MAP_VIEW_NAME);
if (maps && maps.length > 0)
return maps[0].view as MapView;
const maps = app.workspace.getLeavesOfType(consts.MAP_VIEW_NAME);
if (maps && maps.length > 0) return maps[0].view as MapView;
}
export async function getEditor(app: App, leafToUse?: WorkspaceLeaf) : Promise<Editor> {
let view = leafToUse && leafToUse.view instanceof MarkdownView ?
leafToUse.view :
app.workspace.getActiveViewOfType(MarkdownView);
if (view)
return view.editor;
return null;
export async function getEditor(
app: App,
leafToUse?: WorkspaceLeaf
): Promise<Editor> {
let view =
leafToUse && leafToUse.view instanceof MarkdownView
? leafToUse.view
: app.workspace.getActiveViewOfType(MarkdownView);
if (view) return view.editor;
return null;
}

View file

@ -1,277 +1,337 @@
import { App, ButtonComponent, getAllTags, TextComponent, DropdownComponent } from 'obsidian';
import {
App,
ButtonComponent,
getAllTags,
TextComponent,
DropdownComponent,
} from 'obsidian';
import { PluginSettings, MapLightDark, MapState, areStatesEqual, mergeStates } from 'src/settings';
import {
PluginSettings,
MapLightDark,
MapState,
areStatesEqual,
mergeStates,
} from 'src/settings';
import { MapView } from 'src/mapView';
import { NewPresetDialog } from 'src/newPresetDialog';
import MapViewPlugin from 'src/main';
export class ViewControls {
private parentElement: HTMLElement;
private settings: PluginSettings;
private app: App;
private view: MapView;
private plugin: MapViewPlugin;
private parentElement: HTMLElement;
private settings: PluginSettings;
private app: App;
private view: MapView;
private plugin: MapViewPlugin;
public controlsDiv: HTMLDivElement;
private tagsBox: TextComponent;
private mapSourceBox: DropdownComponent;
private sourceMode: DropdownComponent;
public controlsDiv: HTMLDivElement;
private tagsBox: TextComponent;
private mapSourceBox: DropdownComponent;
private sourceMode: DropdownComponent;
private presetsDiv: HTMLDivElement;
private presetsDivContent: HTMLDivElement = null;
private presetsBox: DropdownComponent;
private lastSelectedPresetIndex: number = null;
private lastSelectedPreset: MapState = null;
private presetsDiv: HTMLDivElement;
private presetsDivContent: HTMLDivElement = null;
private presetsBox: DropdownComponent;
private lastSelectedPresetIndex: number = null;
private lastSelectedPreset: MapState = null;
constructor(parentElement: HTMLElement, settings: PluginSettings, app: App, view: MapView, plugin: MapViewPlugin) {
this.parentElement = parentElement;
this.settings = settings;
this.app = app;
this.view = view;
this.plugin = plugin;
}
constructor(
parentElement: HTMLElement,
settings: PluginSettings,
app: App,
view: MapView,
plugin: MapViewPlugin
) {
this.parentElement = parentElement;
this.settings = settings;
this.app = app;
this.view = view;
this.plugin = plugin;
}
getCurrentState(): MapState {
return this.view.getState() as MapState;
}
getCurrentState(): MapState {
return this.view.getState() as MapState;
}
async setNewState(newState: MapState, considerAutoFit: boolean) {
await this.view.setViewState(newState, false, considerAutoFit);
}
async setNewState(newState: MapState, considerAutoFit: boolean) {
await this.view.setViewState(newState, false, considerAutoFit);
}
async setStateByNewMapSource(newSource: number) {
// Update the state assuming the controls are updated
const state = this.getCurrentState();
await this.setNewState({...state, chosenMapSource: newSource}, false);
}
async setStateByNewMapSource(newSource: number) {
// Update the state assuming the controls are updated
const state = this.getCurrentState();
await this.setNewState({ ...state, chosenMapSource: newSource }, false);
}
public tryToGuessPreset() {
// Try to guess the preset based on the current state, and choose it in the dropdown
// (e.g. for when the plugin loads with a state)
const currentState = this.getCurrentState();
const states = [this.settings.defaultState, ...(this.settings.savedStates || [])];
for (const [index, state] of states.entries())
if (areStatesEqual(state, currentState)) {
this.presetsBox.setValue(index.toString());
this.lastSelectedPresetIndex = index;
this.lastSelectedPreset = currentState;
break;
}
}
public tryToGuessPreset() {
// Try to guess the preset based on the current state, and choose it in the dropdown
// (e.g. for when the plugin loads with a state)
const currentState = this.getCurrentState();
const states = [
this.settings.defaultState,
...(this.settings.savedStates || []),
];
for (const [index, state] of states.entries())
if (areStatesEqual(state, currentState)) {
this.presetsBox.setValue(index.toString());
this.lastSelectedPresetIndex = index;
this.lastSelectedPreset = currentState;
break;
}
}
public updateControlsToState() {
this.setMapSourceBoxByState();
this.setTagsBoxByState();
}
public updateControlsToState() {
this.setMapSourceBoxByState();
this.setTagsBoxByState();
}
setMapSourceBoxByState() {
this.mapSourceBox.setValue(this.getCurrentState().chosenMapSource.toString());
}
setMapSourceBoxByState() {
this.mapSourceBox.setValue(
this.getCurrentState().chosenMapSource.toString()
);
}
async setStateByNewTags(newTags: string[]) {
// Update the state assuming the controls are updated
const state = this.getCurrentState();
await this.setNewState({...state, tags: newTags}, newTags.length > 0);
}
async setStateByNewTags(newTags: string[]) {
// Update the state assuming the controls are updated
const state = this.getCurrentState();
await this.setNewState({ ...state, tags: newTags }, newTags.length > 0);
}
async setStateByTagString(tagListAsString: string) {
// Update the state assuming the UI is updated
await this.setStateByNewTags(tagListAsString.split(',').filter(t => t.length > 0));
}
async setStateByTagString(tagListAsString: string) {
// Update the state assuming the UI is updated
await this.setStateByNewTags(
tagListAsString.split(',').filter((t) => t.length > 0)
);
}
setTagsBoxByState() {
// Update the UI based on the state
const state = this.getCurrentState();
this.tagsBox.setValue(state.tags.join(','));
}
setTagsBoxByState() {
// Update the UI based on the state
const state = this.getCurrentState();
this.tagsBox.setValue(state.tags.join(','));
}
public reload() {
if (this.controlsDiv)
this.controlsDiv.remove();
this.createControls();
}
public reload() {
if (this.controlsDiv) this.controlsDiv.remove();
this.createControls();
}
createControls() {
this.controlsDiv = createDiv({
'cls': 'graph-controls'
});
let filtersDiv = this.controlsDiv.createDiv({'cls': 'graph-control-div'});
filtersDiv.innerHTML = `
createControls() {
this.controlsDiv = createDiv({
cls: 'graph-controls',
});
let filtersDiv = this.controlsDiv.createDiv({
cls: 'graph-control-div',
});
filtersDiv.innerHTML = `
<input id="filtersCollapsible" class="toggle" type="checkbox">
<label for="filtersCollapsible" class="lbl-toggle">Filters</label>
`;
const filtersButton = filtersDiv.getElementsByClassName('toggle')[0] as HTMLInputElement;
filtersButton.checked = this.settings.mapControls.filtersDisplayed;
filtersButton.onclick = async () => {
this.settings.mapControls.filtersDisplayed = filtersButton.checked;
this.plugin.saveSettings();
}
let filtersContent = filtersDiv.createDiv({'cls': 'graph-control-content'});
this.tagsBox = new TextComponent(filtersContent);
this.tagsBox.setPlaceholder('Tags, e.g. "#one,#two"');
this.tagsBox.onChange((tagsBox: string) => {
this.setStateByTagString(tagsBox);
});
let tagSuggestions = new DropdownComponent(filtersContent);
tagSuggestions.setValue('Quick add tag');
tagSuggestions.addOption('', 'Quick add tag');
for (const tagName of this.getAllTagNames())
tagSuggestions.addOption(tagName, tagName);
tagSuggestions.onChange(async value => {
let currentTags = this.getCurrentState().tags;
if (currentTags.indexOf(value) < 0) {
const newTags = currentTags.concat(value);
await this.setStateByNewTags(newTags);
this.setTagsBoxByState();
}
tagSuggestions.setValue('Quick add tag');
this.tagsBox.inputEl.focus();
this.tagsBox.onChanged();
});
const filtersButton = filtersDiv.getElementsByClassName(
'toggle'
)[0] as HTMLInputElement;
filtersButton.checked = this.settings.mapControls.filtersDisplayed;
filtersButton.onclick = async () => {
this.settings.mapControls.filtersDisplayed = filtersButton.checked;
this.plugin.saveSettings();
};
let filtersContent = filtersDiv.createDiv({
cls: 'graph-control-content',
});
this.tagsBox = new TextComponent(filtersContent);
this.tagsBox.setPlaceholder('Tags, e.g. "#one,#two"');
this.tagsBox.onChange((tagsBox: string) => {
this.setStateByTagString(tagsBox);
});
let tagSuggestions = new DropdownComponent(filtersContent);
tagSuggestions.setValue('Quick add tag');
tagSuggestions.addOption('', 'Quick add tag');
for (const tagName of this.getAllTagNames())
tagSuggestions.addOption(tagName, tagName);
tagSuggestions.onChange(async (value) => {
let currentTags = this.getCurrentState().tags;
if (currentTags.indexOf(value) < 0) {
const newTags = currentTags.concat(value);
await this.setStateByNewTags(newTags);
this.setTagsBoxByState();
}
tagSuggestions.setValue('Quick add tag');
this.tagsBox.inputEl.focus();
this.tagsBox.onChanged();
});
let viewDiv = this.controlsDiv.createDiv({'cls': 'graph-control-div'});
viewDiv.innerHTML = `
let viewDiv = this.controlsDiv.createDiv({ cls: 'graph-control-div' });
viewDiv.innerHTML = `
<input id="viewCollapsible" class="toggle" type="checkbox">
<label for="viewCollapsible" class="lbl-toggle">View</label>
`;
const viewButton = viewDiv.getElementsByClassName('toggle')[0] as HTMLInputElement;
viewButton.checked = this.settings.mapControls.viewDisplayed;
viewButton.onclick = async () => {
this.settings.mapControls.viewDisplayed = viewButton.checked;
this.plugin.saveSettings();
}
let viewDivContent = viewDiv.createDiv({'cls': 'graph-control-content'});
this.mapSourceBox = new DropdownComponent(viewDivContent);
for (const [index, source] of this.settings.mapSources.entries()) {
this.mapSourceBox.addOption(index.toString(), source.name);
}
this.mapSourceBox.onChange(async (value: string) => {
this.setStateByNewMapSource(parseInt(value));
});
this.setMapSourceBoxByState();
this.sourceMode = new DropdownComponent(viewDivContent);
this.sourceMode.addOptions({auto: 'Auto', light: 'Light', dark: 'Dark'})
.setValue(this.settings.chosenMapMode ?? 'auto')
.onChange(async value => {
this.settings.chosenMapMode = value as MapLightDark;
await this.plugin.saveSettings();
this.view.refreshMap();
});
let goDefault = new ButtonComponent(viewDivContent);
goDefault
.setButtonText('Reset')
.setTooltip('Reset the view to the defined default.')
.onClick(async () => {
this.presetsBox.setValue('0');
await this.choosePresetAndUpdateState(0);
this.updateControlsToState();
});
let fitButton = new ButtonComponent(viewDivContent);
fitButton
.setButtonText('Fit')
.setTooltip('Set the map view to fit all currently-displayed markers.')
.onClick(() => this.view.autoFitMapToMarkers());
const viewButton = viewDiv.getElementsByClassName(
'toggle'
)[0] as HTMLInputElement;
viewButton.checked = this.settings.mapControls.viewDisplayed;
viewButton.onclick = async () => {
this.settings.mapControls.viewDisplayed = viewButton.checked;
this.plugin.saveSettings();
};
let viewDivContent = viewDiv.createDiv({
cls: 'graph-control-content',
});
this.mapSourceBox = new DropdownComponent(viewDivContent);
for (const [index, source] of this.settings.mapSources.entries()) {
this.mapSourceBox.addOption(index.toString(), source.name);
}
this.mapSourceBox.onChange(async (value: string) => {
this.setStateByNewMapSource(parseInt(value));
});
this.setMapSourceBoxByState();
this.sourceMode = new DropdownComponent(viewDivContent);
this.sourceMode
.addOptions({ auto: 'Auto', light: 'Light', dark: 'Dark' })
.setValue(this.settings.chosenMapMode ?? 'auto')
.onChange(async (value) => {
this.settings.chosenMapMode = value as MapLightDark;
await this.plugin.saveSettings();
this.view.refreshMap();
});
let goDefault = new ButtonComponent(viewDivContent);
goDefault
.setButtonText('Reset')
.setTooltip('Reset the view to the defined default.')
.onClick(async () => {
this.presetsBox.setValue('0');
await this.choosePresetAndUpdateState(0);
this.updateControlsToState();
});
let fitButton = new ButtonComponent(viewDivContent);
fitButton
.setButtonText('Fit')
.setTooltip(
'Set the map view to fit all currently-displayed markers.'
)
.onClick(() => this.view.autoFitMapToMarkers());
this.presetsDiv = this.controlsDiv.createDiv({'cls': 'graph-control-div'});
this.presetsDiv.innerHTML = `
this.presetsDiv = this.controlsDiv.createDiv({
cls: 'graph-control-div',
});
this.presetsDiv.innerHTML = `
<input id="presetsCollapsible" class="toggle" type="checkbox">
<label for="presetsCollapsible" class="lbl-toggle">Presets</label>
`;
const presetsButton = this.presetsDiv.getElementsByClassName('toggle')[0] as HTMLInputElement;
presetsButton.checked = this.settings.mapControls.presetsDisplayed;
presetsButton.onclick = async () => {
this.settings.mapControls.presetsDisplayed = presetsButton.checked;
this.plugin.saveSettings();
}
this.refreshPresets();
const presetsButton = this.presetsDiv.getElementsByClassName(
'toggle'
)[0] as HTMLInputElement;
presetsButton.checked = this.settings.mapControls.presetsDisplayed;
presetsButton.onclick = async () => {
this.settings.mapControls.presetsDisplayed = presetsButton.checked;
this.plugin.saveSettings();
};
this.refreshPresets();
this.parentElement.append(this.controlsDiv);
}
this.parentElement.append(this.controlsDiv);
}
getAllTagNames() : string[] {
let tags: string[] = [];
const allFiles = this.app.vault.getFiles();
for (const file of allFiles) {
const fileCache = this.app.metadataCache.getFileCache(file);
if (fileCache && fileCache.tags) {
const fileTagNames = getAllTags(fileCache) || [];
tags = tags.concat(fileTagNames.filter(tagName => tags.indexOf(tagName) < 0));
}
}
tags = tags.sort();
return tags;
}
getAllTagNames(): string[] {
let tags: string[] = [];
const allFiles = this.app.vault.getFiles();
for (const file of allFiles) {
const fileCache = this.app.metadataCache.getFileCache(file);
if (fileCache && fileCache.tags) {
const fileTagNames = getAllTags(fileCache) || [];
tags = tags.concat(
fileTagNames.filter((tagName) => tags.indexOf(tagName) < 0)
);
}
}
tags = tags.sort();
return tags;
}
async choosePresetAndUpdateState(chosenPresetNumber: number) {
// Hacky code, not very happy with it... Entry 0 is the default, then 1 is assumed to be the first saved state
const chosenPreset = chosenPresetNumber == 0 ? this.settings.defaultState : this.settings.savedStates[chosenPresetNumber - 1];
this.lastSelectedPresetIndex = chosenPresetNumber;
this.lastSelectedPreset = mergeStates(this.getCurrentState(), chosenPreset);
await this.setNewState({...chosenPreset}, false);
this.updateControlsToState();
}
async choosePresetAndUpdateState(chosenPresetNumber: number) {
// Hacky code, not very happy with it... Entry 0 is the default, then 1 is assumed to be the first saved state
const chosenPreset =
chosenPresetNumber == 0
? this.settings.defaultState
: this.settings.savedStates[chosenPresetNumber - 1];
this.lastSelectedPresetIndex = chosenPresetNumber;
this.lastSelectedPreset = mergeStates(
this.getCurrentState(),
chosenPreset
);
await this.setNewState({ ...chosenPreset }, false);
this.updateControlsToState();
}
refreshPresets() {
if (this.presetsDivContent)
this.presetsDivContent.remove();
this.presetsDivContent = this.presetsDiv.createDiv({'cls': 'graph-control-content'});
this.presetsBox = new DropdownComponent(this.presetsDivContent);
const states = [this.settings.defaultState, ...(this.settings.savedStates || [])];
this.presetsBox.addOption('-1', '');
for (const [index, preset] of states.entries()) {
this.presetsBox.addOption(index.toString(), preset.name);
}
if (this.lastSelectedPresetIndex && this.lastSelectedPresetIndex < states.length &&
areStatesEqual(this.getCurrentState(), this.lastSelectedPreset))
this.presetsBox.setValue(this.lastSelectedPreset.toString());
this.presetsBox.onChange(async (value: string) => {
const chosenPresetNumber = parseInt(value);
if (chosenPresetNumber == -1)
return;
await this.choosePresetAndUpdateState(chosenPresetNumber);
});
let savePreset = new ButtonComponent(this.presetsDivContent);
savePreset
.setButtonText('Save as...')
.setTooltip('Save the current view as a preset.')
.onClick(() => {
const dialog = new NewPresetDialog(this.app, this.getCurrentState(),
this.plugin, this.settings,
(index: string) => {
// If a new preset was added, this small function makes sure it's selected afterwards
this.refreshPresets();
if (index)
this.presetsBox.setValue(index);
});
dialog.open();
});
let deletePreset = new ButtonComponent(this.presetsDivContent);
deletePreset
.setButtonText('Delete')
.setTooltip('Delete the currently-selected preset.')
.onClick(async () => {
const selectionIndex = parseInt(this.presetsBox.getValue());
if (selectionIndex > 0) {
this.settings.savedStates.splice(selectionIndex - 1, 1);
await this.plugin.saveSettings();
this.refreshPresets();
}
});
let saveAsDefault = new ButtonComponent(this.presetsDivContent);
saveAsDefault
.setButtonText('Save as Default')
.setTooltip('Save the current view as the default one.')
.onClick(async () => {
this.settings.defaultState = {...this.getCurrentState(), name: 'Default'};
await this.plugin.saveSettings();
this.presetsBox.setValue('0');
});
}
refreshPresets() {
if (this.presetsDivContent) this.presetsDivContent.remove();
this.presetsDivContent = this.presetsDiv.createDiv({
cls: 'graph-control-content',
});
this.presetsBox = new DropdownComponent(this.presetsDivContent);
const states = [
this.settings.defaultState,
...(this.settings.savedStates || []),
];
this.presetsBox.addOption('-1', '');
for (const [index, preset] of states.entries()) {
this.presetsBox.addOption(index.toString(), preset.name);
}
if (
this.lastSelectedPresetIndex &&
this.lastSelectedPresetIndex < states.length &&
areStatesEqual(this.getCurrentState(), this.lastSelectedPreset)
)
this.presetsBox.setValue(this.lastSelectedPreset.toString());
this.presetsBox.onChange(async (value: string) => {
const chosenPresetNumber = parseInt(value);
if (chosenPresetNumber == -1) return;
await this.choosePresetAndUpdateState(chosenPresetNumber);
});
let savePreset = new ButtonComponent(this.presetsDivContent);
savePreset
.setButtonText('Save as...')
.setTooltip('Save the current view as a preset.')
.onClick(() => {
const dialog = new NewPresetDialog(
this.app,
this.getCurrentState(),
this.plugin,
this.settings,
(index: string) => {
// If a new preset was added, this small function makes sure it's selected afterwards
this.refreshPresets();
if (index) this.presetsBox.setValue(index);
}
);
dialog.open();
});
let deletePreset = new ButtonComponent(this.presetsDivContent);
deletePreset
.setButtonText('Delete')
.setTooltip('Delete the currently-selected preset.')
.onClick(async () => {
const selectionIndex = parseInt(this.presetsBox.getValue());
if (selectionIndex > 0) {
this.settings.savedStates.splice(selectionIndex - 1, 1);
await this.plugin.saveSettings();
this.refreshPresets();
}
});
let saveAsDefault = new ButtonComponent(this.presetsDivContent);
saveAsDefault
.setButtonText('Save as Default')
.setTooltip('Save the current view as the default one.')
.onClick(async () => {
this.settings.defaultState = {
...this.getCurrentState(),
name: 'Default',
};
await this.plugin.saveSettings();
this.presetsBox.setValue('0');
});
}
invalidateActivePreset() {
if (!areStatesEqual(this.getCurrentState(), this.lastSelectedPreset)) {
this.presetsBox.setValue('-1');
}
}
};
invalidateActivePreset() {
if (!areStatesEqual(this.getCurrentState(), this.lastSelectedPreset)) {
this.presetsBox.setValue('-1');
}
}
}

View file

@ -1,43 +1,43 @@
.map-view-marker-name {
font-weight: bold;
font-weight: bold;
}
.map-view-extra-name {
font-weight: bold;
font-weight: bold;
}
.map-view-marker-snippet {
white-space: pre-line;
overflow-wrap: break-word;
white-space: pre-line;
overflow-wrap: break-word;
}
.map-view-location {
font-weight: bold;
text-decoration: underline;
font-weight: bold;
text-decoration: underline;
}
.graph-controls {
position: fixed;
z-index: 2;
margin-top: 36px;
position: fixed;
z-index: 2;
margin-top: 36px;
}
.graph-control-div {
display: inline-block;
display: inline-block;
}
.graph-control-content {
max-height: 0px;
overflow: hidden;
transition: max-height .25s ease-in-out;
max-height: 0px;
overflow: hidden;
transition: max-height 0.25s ease-in-out;
}
.toggle:checked + .lbl-toggle + .graph-control-content {
max-height: 100vh;
max-height: 100vh;
}
.settings-dense-button {
margin-right: 0;
margin-right: 0;
}
.leaflet-container .dark-mode {
@ -46,20 +46,20 @@
}
.newPresetDialogGrid {
display: grid;
grid-row-gap: 10px;
display: grid;
grid-row-gap: 10px;
}
.newPresetDialogLine {
display: inline-block;
display: inline-block;
}
.clusterPreviewIcon {
margin-left: 0 !important;
margin-top: 0 !important;
position: relative !important;
margin-left: 0 !important;
margin-top: 0 !important;
position: relative !important;
}
.clusterPreviewContainer {
display: inline-flex;
display: inline-flex;
}

View file

@ -1,21 +1,15 @@
{
"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"]
}