├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── api-request.md │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── __mocks__ └── obsidian.ts ├── automation ├── build │ ├── buildBanner.ts │ ├── esbuild.config.ts │ └── esbuild.dev.config.ts ├── config.json ├── release.ts ├── stats.ts ├── tsconfig.json └── utils │ ├── shellUtils.ts │ ├── utils.ts │ └── versionUtils.ts ├── bun.lockb ├── eslint.config.mjs ├── exampleVault └── index.md ├── manifest-beta.json ├── manifest.json ├── package.json ├── src ├── api │ ├── APIManager.ts │ ├── APIModel.ts │ └── apis │ │ ├── BoardGameGeekAPI.ts │ │ ├── ComicVineAPI.ts │ │ ├── GiantBombAPI.ts │ │ ├── MALAPI.ts │ │ ├── MALAPIManga.ts │ │ ├── MobyGamesAPI.ts │ │ ├── MusicBrainzAPI.ts │ │ ├── OMDbAPI.ts │ │ ├── OpenLibraryAPI.ts │ │ ├── SteamAPI.ts │ │ └── WikipediaAPI.ts ├── main.ts ├── modals │ ├── ConfirmOverwriteModal.ts │ ├── MediaDbAdvancedSearchModal.ts │ ├── MediaDbFolderImportModal.ts │ ├── MediaDbIdSearchModal.ts │ ├── MediaDbPreviewModal.ts │ ├── MediaDbSearchModal.ts │ ├── MediaDbSearchResultModal.ts │ ├── SelectModal.ts │ └── SelectModalElement.ts ├── models │ ├── BoardGameModel.ts │ ├── BookModel.ts │ ├── ComicMangaModel.ts │ ├── GameModel.ts │ ├── MediaTypeModel.ts │ ├── MovieModel.ts │ ├── MusicReleaseModel.ts │ ├── SeriesModel.ts │ └── WikiModel.ts ├── settings │ ├── Icon.svelte │ ├── PropertyMapper.ts │ ├── PropertyMapping.ts │ ├── PropertyMappingModelComponent.svelte │ ├── PropertyMappingModelsComponent.svelte │ ├── Settings.ts │ └── suggesters │ │ ├── FileSuggest.ts │ │ ├── FolderSuggest.ts │ │ └── Suggest.ts └── utils │ ├── DateFormatter.ts │ ├── IconList.ts │ ├── MediaType.ts │ ├── MediaTypeManager.ts │ ├── ModalHelper.ts │ └── Utils.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/api-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: API request 3 | about: Suggest a new API to be added to this plugin. 4 | title: '' 5 | labels: API request 6 | assignees: '' 7 | --- 8 | 9 | **Name** 10 | Name of the API you would like to be added to the plugin 11 | 12 | **Link** 13 | A link to their API documentation 14 | 15 | **What does the API do/offer** 16 | A short description of what data the API offers 17 | 18 | - [ ] Is the API free to use 19 | - [ ] Does the API require authentication 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | - [ ] The Plugin is up to date 10 | - [ ] Obsidian is up to date 11 | 12 | **Describe the bug** 13 | A clear and concise description of what the bug is. 14 | 15 | **To Reproduce** 16 | Steps to reproduce the behavior: 17 | 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **Occurs on** 30 | 31 | - [ ] Windows 32 | - [ ] macOS 33 | - [ ] Linux 34 | - [ ] Android 35 | - [ ] iOS 36 | 37 | **Plugin version** 38 | x.x.x 39 | 40 | **Additional context** 41 | Add any other context about the problem here. 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Additional context** 16 | Add any other context or screenshots about the feature request here. 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Plugin Release 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10 8 | 9 | env: 10 | PLUGIN_NAME: obsidian-media-db-plugin # Change this to the name of your plugin-id folder 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Determine prerelease status 21 | id: status 22 | run: | 23 | if [[ "${{ github.ref }}" == *"canary"* ]]; then 24 | echo "prerelease=true" >> $GITHUB_OUTPUT 25 | else 26 | echo "prerelease=false" >> $GITHUB_OUTPUT 27 | fi 28 | 29 | - name: Install Bun 30 | uses: oven-sh/setup-bun@v1 31 | with: 32 | bun-version: latest 33 | 34 | - name: Build 35 | id: build 36 | run: | 37 | bun install 38 | bun run build 39 | mkdir ${{ env.PLUGIN_NAME }} 40 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 41 | zip -r ${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip ${{ env.PLUGIN_NAME }} 42 | ls 43 | 44 | - name: Release 45 | id: release 46 | uses: softprops/action-gh-release@v2 47 | with: 48 | prerelease: ${{ steps.status.outputs.prerelease }} 49 | token: ${{ secrets.GITHUB_TOKEN }} 50 | files: | 51 | ${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip 52 | main.js 53 | manifest.json 54 | styles.css 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | src/**/*.js 25 | __mocks__/*.js 26 | 27 | obsidian.css 28 | 29 | !exampleVault/.obsidian 30 | 31 | exampleVault/.obsidian/* 32 | !exampleVault/.obsidian/plugins 33 | 34 | exampleVault/.obsidian/plugins/* 35 | exampleVault/.obsidian/plugins/obsidian-media-db-plugin/* 36 | !exampleVault/.obsidian/plugins/obsidian-media-db-plugin/.hotreload 37 | 38 | exampleVault/Media DB/* 39 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /lib 2 | node_modules 3 | package-lock.json 4 | data.json 5 | main.js 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 180, 3 | "useTabs": true, 4 | "semi": true, 5 | "singleQuote": true, 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | # 0.8.0 4 | 5 | - Fixed bugs when API keys for certain APIs were missing [#161](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/161) (thanks ltctceplrm) 6 | - Added support for other languages when remapping fields [#162](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/162) (thanks ltctceplrm) 7 | - Added support for the `Giant Bomb` API [#166](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/166) (thanks ltctceplrm) 8 | - Migration to Svelte 5 9 | - Some internal changes and improved error handling 10 | 11 | # 0.7.2 12 | 13 | - Improvements to UI text to match the Obsidian plugin guidelines [#153](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/153) (thanks kepano) 14 | 15 | # 0.7.1 16 | 17 | - Fixed mobygames result without an image crashing the search [#148](https://github.com/mProjectsCode/obsidian-media-db-plugin/issues/148) [#149](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/149) (thanks ltctceplrm) 18 | - Use Steam Community SearchApps for Steam search by title for fuzzy results [#146](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/146) (thanks ZackBoe) 19 | - Don't search APIs that don't have an API key set [#147](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/147) (thanks ZackBoe) 20 | - Use https for all API requests [#147](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/147) (thanks ZackBoe) 21 | - Sped up multi API search 22 | - Fixed unrelated APIs being searched when searching by a specific media type 23 | 24 | # 0.7.0 25 | 26 | - renamed the plugin to just `Media DB` 27 | - Add plot field when fetching data from OMDb [#106](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/106) (thanks onesvat) 28 | - Added support for Moby Games API [#131](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/131) (thanks ltctceplrm) 29 | - Add index operator for arrays when templating [#129](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/129) (thanks kelszo) 30 | - Support disabling default front matter and add support for Templater [#119](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/119) (thanks kelszo) 31 | - Add option to open new note in a new tab [#128](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/128) (thanks kelszo) 32 | - Added developers and publishers field to games [#122](https://github.com/mProjectsCode/obsidian-media-db-plugin/pull/122) (thanks ltctceplrm) 33 | 34 | # 0.6.0 35 | 36 | - Added manga support through Jikan 37 | - Added book support through Open Library 38 | - Added album cover support for music releases 39 | - Split up `producer` into `studio`, `director` and `writer` for movies and series 40 | - fixed the preview modal not displaying the frontmatter anymore 41 | 42 | # 0.5.0 43 | 44 | - New simple search modal, select the media type and search all applicable APIs 45 | - More data for Board Games 46 | - Actors and Streaming Platforms for Movies and Series 47 | - Separate new file location for all media types 48 | - Separate command for each media type 49 | - Fix problems with closing of preview modal 50 | 51 | # 0.3.2 52 | 53 | - Added Board Game Geek API (documentation pending) 54 | - More information in the search results 55 | - various fixes 56 | 57 | # 0.3.1 58 | 59 | - various fixes 60 | 61 | # 0.3.0 62 | 63 | - Added bulk import. Import a folder of media notes as Media DB entries (thanks to [PaperOrb](https://github.com/PaperOrb) on GitHub for their input and for helping me test this feature) 64 | - Added a custom result select modal that allows you to select multiple results at once 65 | - Fixed a bug where the note creation would fail when the metadata included a field with the values `null` or `undefined` 66 | 67 | # 0.2.1 68 | 69 | - fixed a small bug with the initial selection of an API in the ID search modal 70 | 71 | # 0.2.0 72 | 73 | - Added the option to rename metadata fields through property mappings 74 | - fixed note creation falling, when the folder set in the settings did not exist 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Obsidian Media DB Plugin 2 | 3 | A plugin that can query multiple APIs for movies, series, anime, manga, books, games, music and wiki articles, and import them into your vault. 4 | 5 | ### Features 6 | 7 | #### Search by Title 8 | 9 | Search a movie, series, anime, game, music release or wiki article by its name across multiple APIs. 10 | 11 | #### Search by ID 12 | 13 | Allows you to search by an ID that varies from API to API. Concrete information on this feature can be found in the description of the individual APIs. 14 | 15 | #### Templates 16 | 17 | The plugin allows you to set a template note that gets added to the end of any note created by this plugin. 18 | The plugin also offers simple "template tgs". E.g. if the template includes `{{ title }}`, it will be replaced by the title of the movie, show or game. 19 | Note that "template tags" are surrounded with two curly braces and that the spaces inside the curly braces are important. 20 | 21 | For arrays there are two special ways of displaying them. 22 | 23 | - using `{{ LIST:variable_name }}` will result in 24 | ``` 25 | - element 1 26 | - element 2 27 | - element 3 28 | - ... 29 | ``` 30 | - using `{{ ENUM:variable_name }}` will result in 31 | ``` 32 | element 1, element 2, element 3, ... 33 | ``` 34 | 35 | Available variables that can be used in template tags are the same variables from the metadata of the note. 36 | 37 | I also published my own templates [here](https://github.com/mProjectsCode/obsidian-media-db-templates). 38 | 39 | #### Download poster images 40 | 41 | Allows you to automatically download the poster images for a new media, ensuring offline access. The images are saved as `type_title (year)` e.g. `movie_The Perfect Storm (2000)` with a user chosen save location. 42 | 43 | #### Metadata field customization 44 | 45 | Allows you to rename the metadata fields this plugin generates through mappings. 46 | 47 | A mapping has to follow this syntax `[origional property name] -> [new property name]`. 48 | Multiple mappings are separated by a new line. 49 | So e.g.: 50 | 51 | ``` 52 | title -> name 53 | year -> releaseYear 54 | ``` 55 | 56 | #### Bulk Import 57 | 58 | The plugin allows you to import your preexisting media collection and upgrade them to Media DB entries. 59 | 60 | ##### Prerequisites 61 | 62 | The preexisting media notes must be inside a folder in your vault. 63 | For the plugin to be able to query them they need one metadata field that is used as the title the piece of media is searched by. 64 | This can be achieved by for example using a `csv` import plugin to import an existing list from outside of obsidian. 65 | 66 | ##### Importing 67 | 68 | To start the import process, right-click on the folder and select the `Import folder as Media DB entries` option. 69 | Then specify the API to search, if the current note content and metadata should be appended to the Media DB entry and the name of the metadata field that contains the title of the piece of media. 70 | 71 | Then the plugin will go through every file in the folder and prompt you to select from the search results. 72 | 73 | ##### Post import 74 | 75 | After all files have been imported or the import was canceled, you will find the new entries as well as an error report that contains any errors or skipped/canceled files in the folder specified in the setting of the plugin. 76 | 77 | ### How to install 78 | 79 | **The plugin is now released, so it can be installed directly through obsidian's plugin installer.** 80 | 81 | Alternatively, you can manually download the zip archive from the latest release here on GitHub. 82 | After downloading, extract the archive into the `.obsidian/plugins` folder in your vault. 83 | 84 | The folder structure should look like this: 85 | 86 | ``` 87 | [path to your vault] 88 | |_ .obsidian 89 | |_ plugins 90 | |_ obsidian-media-db-plugin 91 | |_ main.js 92 | |_ manifest.json 93 | |_ styles.css 94 | ``` 95 | 96 | ### How to use 97 | 98 | (pictures are coming) 99 | 100 | Once you have installed this plugin, you will find a database icon in the left ribbon. 101 | When using this or the `Add new Media DB entry` command, a popup will open. 102 | Here you can enter the title of what you want to search for and then select in which APIs to search. 103 | 104 | After clicking search, a new popup will open prompting you to select from the search results. 105 | Now you select the result you want and the plugin will cast it's magic and create a new note in your vault, that contains the metadata of the selected search result. 106 | 107 | ### Currently supported media types 108 | 109 | - movies (including specials) 110 | - series (including OVAs) 111 | - games 112 | - music releases 113 | - wiki articles 114 | - books 115 | - manga 116 | - comics 117 | 118 | ### Currently supported APIs: 119 | 120 | | Name | Description | Supported formats | Authentification | Rate limiting | SFW filter support | 121 | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | 122 | | [Jikan](https://jikan.moe/) | Jikan is an API that uses [My Anime List](https://myanimelist.net) and offers metadata for anime. | series, movies, specials, OVAs, manga, manwha, novels | No | 60 per minute and 3 per second | Yes | 123 | | [OMDb](https://www.omdbapi.com/) | OMDb is an API that offers metadata for movie, series and games. | series, movies, games | Yes, you can get a free key here [here](https://www.omdbapi.com/apikey.aspx) | 1000 per day | No | 124 | | [MusicBrainz](https://musicbrainz.org/) | MusicBrainz is an API that offers information about music releases. | music releases | No | 50 per second | No | 125 | | [Wikipedia](https://en.wikipedia.org/wiki/Main_Page) | The Wikipedia API allows access to all Wikipedia articles. | wiki articles | No | None | No | 126 | | [Steam](https://store.steampowered.com/) | The Steam API offers information on all steam games. | games | No | 10000 per day | No | 127 | | [Open Library](https://openlibrary.org) | The OpenLibrary API offers metadata for books | books | No | Cover access is rate-limited when not using CoverID or OLID by max 100 requests/IP every 5 minutes. This plugin uses OLID so there shouldn't be a rate limit. | No | 128 | | [Moby Games](https://www.mobygames.com) | The Moby Games API offers metadata for games for all platforms | games | Yes, by making an account [here](https://www.mobygames.com/user/register/). NOTE: As of September 2024 the API key is no longer free so consider using Giant Bomb or steam instead | API requests are limited to 360 per hour (one every ten seconds). In addition, requests should be made no more frequently than one per second. | No | 129 | | [Giant Bomb](https://www.giantbomb.com) | The Giant Bomb API offers metadata for games for all platforms | games | Yes, by making an account [here](https://www.giantbomb.com/login-signup/) | API requests are limited to 200 requests per resource, per hour. In addition, they implement velocity detection to prevent malicious use. If too many requests are made per second, you may receive temporary blocks to resources. | No | 130 | | Comic Vine | The Comic Vine API offers metadata for comic books | comicbooks | Yes, by making an account [here](https://comicvine.gamespot.com/login-signup/) and going to the [api section](https://comicvine.gamespot.com/api/) of the site | 200 requests per resource, per hour. There is also a velocity detection to prevent malicious use. If too many requests are made per second, you may receive temporary blocks to resources. | No | 131 | 132 | #### Notes 133 | 134 | - [Jikan](https://jikan.moe/) 135 | - sometimes the api is very slow, this is normal 136 | - you need to use the title the anime has on [My Anime List](https://myanimelist.net), which is in most cases the japanese title 137 | - e.g. instead of "Demon Slayer" you have to search "Kimetsu no Yaiba" 138 | 139 | #### Search by ID 140 | 141 | - [Jikan](https://jikan.moe/) 142 | - the ID you need is the ID of the anime on [My Anime List](https://myanimelist.net) 143 | - you can find this ID in the URL 144 | - e.g. for "Beyond the Boundary" the URL looks like this `https://myanimelist.net/anime/18153/Kyoukai_no_Kanata` so the ID is `18153` 145 | - [Jikan Manga](https://jikan.moe/) 146 | - the ID you need is the ID of the manga on [My Anime List](https://myanimelist.net) 147 | - you can find this ID in the URL 148 | - e.g. for "All You Need Is Kill" the URL looks like this `https://myanimelist.net/manga/62887/All_You_Need_Is_Kill` so the ID is `62887` 149 | - [OMDb](https://www.omdbapi.com/) 150 | - the ID you need is the ID of the movie or show on [IMDb](https://www.imdb.com) 151 | - you can find this ID in the URL 152 | - e.g. for "Rogue One" the URL looks like this `https://www.imdb.com/title/tt3748528/` so the ID is `tt3748528` 153 | - [MusicBrainz](https://musicbrainz.org/) 154 | - the id of a release is not easily accessible, you are better off just searching by title 155 | - the search is generally for albums but you can have a more granular search like so: 156 | - search for albums by a specific `artist:"Lady Gaga" AND primarytype:"album"` 157 | - search for a specific album by a specific artist `artist:"Lady Gaga" AND primarytype:"album" AND releasegroup:"The Fame"` 158 | - search for a specific entry (song or album) by a specific `artist:"Lady Gaga" AND releasegroup:"Poker face"` 159 | - [Wikipedia](https://en.wikipedia.org/wiki/Main_Page) 160 | - [here](https://en.wikipedia.org/wiki/Wikipedia:Finding_a_Wikidata_ID) is a guide to finding the Wikipedia ID for an article 161 | - [Steam](https://store.steampowered.com/) 162 | - you can find this ID in the URL 163 | - e.g. for "Factorio" the URL looks like this `https://store.steampowered.com/app/427520/Factorio/` so the ID is `427520` 164 | - [Open Library](https://openlibrary.org) 165 | - The ID you need is the "work" ID and not the "book" ID, it needs to start with `/works/`. You can find this ID in the URL 166 | - e.g. for "Fantastic Mr. Fox" the URL looks like this `https://openlibrary.org/works/OL45804W` so the ID is `/works/OL45804W` 167 | - This URL is located near the top of the page above the title, see `An edition of Fantastic Mr Fox (1970) ` 168 | - [Moby Games](https://www.mobygames.com) 169 | - you can find this ID in the URL 170 | - e.g. for "Bioshock 2" the URL looks like this `https://www.mobygames.com/game/45089/bioshock-2/` so the ID is `45089` 171 | - [Giant Bomb](https://www.giantbomb.com) 172 | - you can find this ID in the URL 173 | - e.g. for "Dota 2" the URL looks like this `https://www.giantbomb.com/dota-2/3030-32887/` so the ID is `3030-32887` 174 | - [Comic Vine](https://www.comicvine.gamespot.com) 175 | - you can find this ID in the URL 176 | - e.g. for "Boule & Bill" the URL looks like this `https://comicvine.gamespot.com/boule-bill/4050-70187/` so the ID is `4050-70187` 177 | - Please note that only volumes can be added, not separate issues. 178 | 179 | ### Problems, unexpected behavior or improvement suggestions? 180 | 181 | You are more than welcome to open an issue on [GitHub](https://github.com/mProjectsCode/obsidian-media-db-plugin/issues). 182 | 183 | ### Contributions 184 | 185 | Thank you for wanting to contribute to this project. 186 | 187 | Contributions are always welcome. If you have an idea, feel free to open a feature request under the issue tab or even create a pull request. 188 | 189 | ### Credits 190 | 191 | Credits go to: 192 | 193 | - https://github.com/anpigon/obsidian-book-search-plugin for some inspiration and the idea to make this plugin 194 | - https://github.com/liamcain/obsidian-periodic-notes for 99% of `Suggest.ts` and `FolderSuggest.ts` 195 | -------------------------------------------------------------------------------- /__mocks__/obsidian.ts: -------------------------------------------------------------------------------- 1 | import { RequestUrlParam, RequestUrlResponse } from 'obsidian'; 2 | 3 | export function requestUrl(request: RequestUrlParam): Promise { 4 | return fetch(request.url, { 5 | method: request.method, 6 | headers: request.headers, 7 | body: request.body, 8 | }).then(async response => { 9 | if (response.status >= 400 && request.throw) { 10 | throw new Error(`Request failed, ${response.status}`); 11 | } 12 | 13 | // Turn response headers into Record object 14 | const headers: Record = {}; 15 | response.headers.forEach((value, key) => { 16 | headers[key] = value; 17 | }); 18 | 19 | const arraybuffer = await response.arrayBuffer(); 20 | const text = arraybuffer ? new TextDecoder().decode(arraybuffer) : ''; 21 | const json = text ? JSON.parse(text) : {}; 22 | 23 | let response_body: RequestUrlResponse = { 24 | status: response.status, 25 | headers: headers, 26 | arrayBuffer: arraybuffer, 27 | json: json, 28 | text: text, 29 | }; 30 | return response_body; 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /automation/build/buildBanner.ts: -------------------------------------------------------------------------------- 1 | import manifest from '../../manifest.json' assert { type: 'json' }; 2 | 3 | export function getBuildBanner(buildType: string, getVersion: (version: string) => string) { 4 | return `/* 5 | ------------------------------------------- 6 | ${manifest.name} - ${buildType} 7 | ------------------------------------------- 8 | By: ${manifest.author} (${manifest.authorUrl}) 9 | Time: ${new Date().toUTCString()} 10 | Version: ${getVersion(manifest.version)} 11 | ------------------------------------------- 12 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 13 | if you want to view the source, please visit the github repository of this plugin 14 | ------------------------------------------- 15 | MIT License 16 | 17 | Copyright (c) ${new Date().getFullYear()} ${manifest.author} 18 | 19 | Permission is hereby granted, free of charge, to any person obtaining a copy 20 | of this software and associated documentation files (the "Software"), to deal 21 | in the Software without restriction, including without limitation the rights 22 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 23 | copies of the Software, and to permit persons to whom the Software is 24 | furnished to do so, subject to the following conditions: 25 | 26 | The above copyright notice and this permission notice shall be included in all 27 | copies or substantial portions of the Software. 28 | 29 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 30 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 31 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 32 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 33 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 34 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 35 | SOFTWARE. 36 | */ 37 | `; 38 | } 39 | -------------------------------------------------------------------------------- /automation/build/esbuild.config.ts: -------------------------------------------------------------------------------- 1 | import builtins from 'builtin-modules'; 2 | import esbuild from 'esbuild'; 3 | import esbuildSvelte from 'esbuild-svelte'; 4 | import { sveltePreprocess } from 'svelte-preprocess'; 5 | import { getBuildBanner } from 'build/buildBanner'; 6 | 7 | const banner = getBuildBanner('Release Build', version => version); 8 | 9 | const build = await esbuild.build({ 10 | banner: { 11 | js: banner, 12 | }, 13 | entryPoints: ['src/main.ts'], 14 | bundle: true, 15 | external: [ 16 | 'obsidian', 17 | 'electron', 18 | '@codemirror/autocomplete', 19 | '@codemirror/collab', 20 | '@codemirror/commands', 21 | '@codemirror/language', 22 | '@codemirror/lint', 23 | '@codemirror/search', 24 | '@codemirror/state', 25 | '@codemirror/view', 26 | '@lezer/common', 27 | '@lezer/highlight', 28 | '@lezer/lr', 29 | ...builtins, 30 | ], 31 | format: 'cjs', 32 | target: 'es2018', 33 | logLevel: 'info', 34 | sourcemap: false, 35 | treeShaking: true, 36 | outfile: 'main.js', 37 | minify: true, 38 | metafile: true, 39 | define: { 40 | MB_GLOBAL_CONFIG_DEV_BUILD: 'false', 41 | }, 42 | plugins: [ 43 | esbuildSvelte({ 44 | compilerOptions: { css: 'injected', dev: false }, 45 | preprocess: sveltePreprocess(), 46 | filterWarnings: warning => { 47 | // we don't want warnings from node modules that we can do nothing about 48 | return !warning.filename?.includes('node_modules'); 49 | }, 50 | }), 51 | ], 52 | }); 53 | 54 | const file = Bun.file('meta.txt'); 55 | await Bun.write(file, JSON.stringify(build.metafile, null, '\t')); 56 | 57 | process.exit(0); 58 | -------------------------------------------------------------------------------- /automation/build/esbuild.dev.config.ts: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import copy from 'esbuild-plugin-copy-watch'; 3 | import esbuildSvelte from 'esbuild-svelte'; 4 | import { sveltePreprocess } from 'svelte-preprocess'; 5 | import manifest from '../../manifest.json' assert { type: 'json' }; 6 | import { getBuildBanner } from 'build/buildBanner'; 7 | 8 | const banner = getBuildBanner('Dev Build', _ => 'Dev Build'); 9 | 10 | const context = await esbuild.context({ 11 | banner: { 12 | js: banner, 13 | }, 14 | entryPoints: ['src/main.ts'], 15 | bundle: true, 16 | external: [ 17 | 'obsidian', 18 | 'electron', 19 | '@codemirror/autocomplete', 20 | '@codemirror/collab', 21 | '@codemirror/commands', 22 | '@codemirror/language', 23 | '@codemirror/lint', 24 | '@codemirror/search', 25 | '@codemirror/state', 26 | '@codemirror/view', 27 | '@lezer/common', 28 | '@lezer/highlight', 29 | '@lezer/lr', 30 | ], 31 | format: 'cjs', 32 | target: 'es2018', 33 | logLevel: 'info', 34 | sourcemap: 'inline', 35 | treeShaking: true, 36 | outdir: `exampleVault/.obsidian/plugins/${manifest.id}/`, 37 | outbase: 'src', 38 | define: { 39 | MB_GLOBAL_CONFIG_DEV_BUILD: 'true', 40 | }, 41 | plugins: [ 42 | copy({ 43 | paths: [ 44 | { 45 | from: './styles.css', 46 | to: '', 47 | }, 48 | { 49 | from: './manifest.json', 50 | to: '', 51 | }, 52 | ], 53 | }), 54 | esbuildSvelte({ 55 | compilerOptions: { css: 'injected', dev: true }, 56 | preprocess: sveltePreprocess(), 57 | filterWarnings: warning => { 58 | // we don't want warnings from node modules that we can do nothing about 59 | return !warning.filename?.includes('node_modules'); 60 | }, 61 | }), 62 | ], 63 | }); 64 | 65 | await context.watch(); 66 | -------------------------------------------------------------------------------- /automation/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "devBranch": "master", 3 | "releaseBranch": "release", 4 | "github": "https://github.com/mProjectsCode/obsidian-media-db-plugin" 5 | } 6 | -------------------------------------------------------------------------------- /automation/release.ts: -------------------------------------------------------------------------------- 1 | import { UserError } from 'utils/utils'; 2 | import { CanaryVersion, Version, getIncrementOptions, parseVersion, stringifyVersion } from 'utils/versionUtils'; 3 | import config from './config.json'; 4 | import { $choice as $choice, $confirm, $seq, CMD_FMT, Verboseness } from 'utils/shellUtils'; 5 | 6 | async function runPreconditions(): Promise { 7 | // run preconditions 8 | await $seq( 9 | [`bun run format`, `bun run test`], 10 | (cmd: string) => { 11 | throw new UserError(`precondition "${cmd}" failed`); 12 | }, 13 | () => {}, 14 | undefined, 15 | Verboseness.VERBOSE, 16 | ); 17 | 18 | // add changed files 19 | await $seq( 20 | [`git add .`], 21 | () => { 22 | throw new UserError('failed to add preconditions changes to git'); 23 | }, 24 | () => {}, 25 | undefined, 26 | Verboseness.NORMAL, 27 | ); 28 | 29 | // check if there were any changes 30 | let changesToCommit = false; 31 | await $seq( 32 | [`git diff --quiet`, `git diff --cached --quiet`], 33 | () => { 34 | changesToCommit = true; 35 | }, 36 | () => {}, 37 | undefined, 38 | Verboseness.QUITET, 39 | ); 40 | 41 | // if there were any changes, commit them 42 | if (changesToCommit) { 43 | await $seq( 44 | [`git commit -m "[auto] run release preconditions"`], 45 | () => { 46 | throw new UserError('failed to add preconditions changes to git'); 47 | }, 48 | () => {}, 49 | undefined, 50 | Verboseness.NORMAL, 51 | ); 52 | } 53 | } 54 | 55 | async function run() { 56 | console.log('looking for untracked changes ...'); 57 | 58 | // check for any uncommited files and exit if there are any 59 | await $seq( 60 | [`git add .`, `git diff --quiet`, `git diff --cached --quiet`, `git checkout ${config.devBranch}`], 61 | () => { 62 | throw new UserError('there are still untracked changes'); 63 | }, 64 | () => {}, 65 | undefined, 66 | Verboseness.QUITET, 67 | ); 68 | 69 | console.log('\nrunning preconditions ...\n'); 70 | 71 | await runPreconditions(); 72 | 73 | console.log('\nbumping versions ...\n'); 74 | 75 | const manifestFile = Bun.file('./manifest.json'); 76 | const manifest = await manifestFile.json(); 77 | 78 | const versionString: string = manifest.version; 79 | const currentVersion: Version = parseVersion(versionString); 80 | const currentVersionString = stringifyVersion(currentVersion); 81 | 82 | const versionIncrementOptions = getIncrementOptions(currentVersion); 83 | 84 | const selectedIndex = await $choice( 85 | `Current version "${currentVersionString}". Select new version`, 86 | versionIncrementOptions.map(x => stringifyVersion(x)), 87 | ); 88 | const newVersion = versionIncrementOptions[selectedIndex]; 89 | const newVersionString = stringifyVersion(newVersion); 90 | 91 | console.log(''); 92 | 93 | await $confirm(`Version will be updated "${currentVersionString}" -> "${newVersionString}". Are you sure`, () => { 94 | throw new UserError('user canceled script'); 95 | }); 96 | 97 | if (!(newVersion instanceof CanaryVersion)) { 98 | manifest.version = newVersionString; 99 | } 100 | 101 | await Bun.write(manifestFile, JSON.stringify(manifest, null, '\t')); 102 | 103 | const betaManifest = structuredClone(manifest); 104 | betaManifest.version = newVersionString; 105 | 106 | const betaManifestFile = Bun.file('./manifest-beta.json'); 107 | await Bun.write(betaManifestFile, JSON.stringify(betaManifest, null, '\t')); 108 | 109 | if (!(newVersion instanceof CanaryVersion)) { 110 | const versionsFile = Bun.file('./versions.json'); 111 | const versionsJson = await versionsFile.json(); 112 | 113 | versionsJson[newVersionString] = manifest.minAppVersion; 114 | 115 | await Bun.write(versionsFile, JSON.stringify(versionsJson, null, '\t')); 116 | 117 | const packageFile = Bun.file('./package.json'); 118 | const packageJson = await packageFile.json(); 119 | 120 | packageJson.version = newVersionString; 121 | 122 | await Bun.write(packageFile, JSON.stringify(packageJson, null, '\t')); 123 | } 124 | 125 | await $seq( 126 | [`bun run format`, `git add .`, `git commit -m "[auto] bump version to \`${newVersionString}\`"`], 127 | () => { 128 | throw new UserError('failed to add preconditions changes to git'); 129 | }, 130 | () => {}, 131 | undefined, 132 | Verboseness.NORMAL, 133 | ); 134 | 135 | console.log('\ncreating release tag ...\n'); 136 | 137 | await $seq( 138 | [ 139 | `git checkout ${config.releaseBranch}`, 140 | `git merge ${config.devBranch} --commit -m "[auto] merge \`${newVersionString}\` release commit"`, 141 | `git push origin ${config.releaseBranch}`, 142 | `git tag -a ${newVersionString} -m "release version ${newVersionString}"`, 143 | `git push origin ${newVersionString}`, 144 | `git checkout ${config.devBranch}`, 145 | `git merge ${config.releaseBranch}`, 146 | `git push origin ${config.devBranch}`, 147 | ], 148 | () => { 149 | throw new UserError('failed to merge or create tag'); 150 | }, 151 | () => {}, 152 | undefined, 153 | Verboseness.NORMAL, 154 | ); 155 | 156 | console.log(''); 157 | 158 | console.log(`${CMD_FMT.BgGreen}done${CMD_FMT.Reset}`); 159 | console.log(`${config.github}`); 160 | console.log(`${config.github}/releases/tag/${newVersionString}`); 161 | } 162 | 163 | try { 164 | await run(); 165 | } catch (e) { 166 | if (e instanceof UserError) { 167 | console.error(e.message); 168 | } else { 169 | console.error(e); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /automation/stats.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | interface Stat { 4 | fileType: string; 5 | count: number; 6 | lines: number; 7 | } 8 | 9 | abstract class StatsBase { 10 | parent: StatsBase | undefined; 11 | path: string; 12 | name: string; 13 | stats: Stat[]; 14 | 15 | constructor(parent: StatsBase | undefined, path: string, name: string, stats: Stat[]) { 16 | this.parent = parent; 17 | 18 | this.path = path; 19 | this.name = name; 20 | this.stats = stats; 21 | } 22 | 23 | abstract addChild(child: StatsBase): void; 24 | 25 | abstract mergeStats(stats: Stat[]): void; 26 | 27 | abstract print(depth: number, lastChildArr: boolean[]): void; 28 | 29 | abstract sort(): void; 30 | 31 | getPrefix(depth: number, lastChildArr: boolean[]): string { 32 | let prefix = ''; 33 | for (let i = 0; i < depth; i++) { 34 | prefix += lastChildArr[i] ? ' ' : '│ '; 35 | } 36 | 37 | if (lastChildArr.at(-1)) { 38 | prefix += '└─ '; 39 | } else { 40 | prefix += '├─ '; 41 | } 42 | 43 | return prefix; 44 | } 45 | } 46 | 47 | class FolderStats extends StatsBase { 48 | children: StatsBase[]; 49 | 50 | constructor(parent: StatsBase | undefined, path: string, name: string) { 51 | super(parent, path, name, []); 52 | 53 | this.children = []; 54 | } 55 | 56 | addChild(child: StatsBase) { 57 | this.children.push(child); 58 | this.mergeStats(child.stats); 59 | } 60 | 61 | mergeStats(stats: Stat[]): void { 62 | // console.log(this, stats); 63 | for (const stat of stats) { 64 | const existingStat = this.stats.find(s => s.fileType === stat.fileType); 65 | if (existingStat) { 66 | existingStat.count += stat.count; 67 | existingStat.lines += stat.lines; 68 | } else { 69 | this.stats.push(structuredClone(stat)); 70 | } 71 | } 72 | 73 | this.parent?.mergeStats(stats); 74 | } 75 | 76 | print(depth: number, lastChildArr: boolean[]): void { 77 | console.log( 78 | `${this.getPrefix(depth, lastChildArr)}${this.name} | ${this.stats.reduce((acc, s) => acc + s.count, 0)} files | ${this.stats.reduce((acc, s) => acc + s.lines, 0)} lines`, 79 | ); 80 | for (let i = 0; i < this.children.length; i++) { 81 | const child = this.children[i]; 82 | child.print(depth + 1, [...lastChildArr, i === this.children.length - 1]); 83 | } 84 | } 85 | 86 | sort(): void { 87 | this.children.sort((a, b) => { 88 | if (a instanceof FolderStats && b instanceof FileStats) { 89 | return 1; 90 | } else if (a instanceof FileStats && b instanceof FolderStats) { 91 | return -1; 92 | } else { 93 | return a.name.localeCompare(b.name); 94 | } 95 | }); 96 | this.children.forEach(c => c.sort()); 97 | } 98 | } 99 | 100 | class FileStats extends StatsBase { 101 | constructor(parent: StatsBase, path: string, name: string, stats: Stat[]) { 102 | super(parent, path, name, stats); 103 | } 104 | 105 | addChild(_child: StatsBase): void { 106 | throw new Error('Cannot add child to file'); 107 | } 108 | 109 | mergeStats(_stats: Stat[]): void { 110 | throw new Error('Cannot merge stats to file'); 111 | } 112 | 113 | print(depth: number, lastChildArr: boolean[]): void { 114 | console.log(`${this.getPrefix(depth, lastChildArr)}${this.name} | ${this.stats[0].lines} lines`); 115 | } 116 | 117 | sort(): void {} 118 | } 119 | 120 | function collectStats() { 121 | const root = new FolderStats(undefined, './src', 'src'); 122 | const ignore = ['node_modules', 'extraTypes', 'bun.lockb']; 123 | 124 | const todo: FolderStats[] = [root]; 125 | 126 | while (todo.length > 0) { 127 | const current = todo.pop()!; 128 | const children = fs.readdirSync(current.path, { withFileTypes: true }); 129 | 130 | for (const child of children) { 131 | if (ignore.includes(child.name)) { 132 | continue; 133 | } 134 | 135 | if (child.isDirectory()) { 136 | const folder = new FolderStats(current, `${current.path}/${child.name}`, child.name); 137 | current.addChild(folder); 138 | 139 | todo.push(folder); 140 | } else { 141 | const content = fs.readFileSync(`${current.path}/${child.name}`, 'utf-8'); 142 | 143 | const file = new FileStats(current, `${current.path}/${child.name}`, child.name, [ 144 | { 145 | fileType: child.name.split('.').splice(1).join('.'), 146 | count: 1, 147 | lines: content.split('\n').length, 148 | }, 149 | ]); 150 | current.addChild(file); 151 | } 152 | } 153 | } 154 | 155 | root.sort(); 156 | 157 | root.print(0, [true]); 158 | } 159 | 160 | collectStats(); 161 | -------------------------------------------------------------------------------- /automation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "allowJs": true, 7 | "noImplicitAny": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "noImplicitReturns": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "lib": ["DOM", "ES5", "ES6", "ES7", "Es2021"], 15 | "types": ["bun-types"], 16 | "allowSyntheticDefaultImports": true, 17 | "resolveJsonModule": true 18 | }, 19 | "include": ["**/*.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /automation/utils/shellUtils.ts: -------------------------------------------------------------------------------- 1 | import { Subprocess } from 'bun'; 2 | import stringArgv from 'string-argv'; 3 | 4 | export enum Verboseness { 5 | QUITET, 6 | NORMAL, 7 | VERBOSE, 8 | } 9 | 10 | function exec(c: string, cwd?: string): Subprocess<'ignore', 'pipe', 'inherit'> { 11 | return Bun.spawn(stringArgv(c), { cwd: cwd }); 12 | } 13 | 14 | export async function $(cmd: string, cwd?: string | undefined, verboseness: Verboseness = Verboseness.NORMAL): Promise<{ stdout: string; stderr: string; exit: number }> { 15 | if (verboseness === Verboseness.NORMAL || verboseness === Verboseness.VERBOSE) { 16 | if (cwd !== undefined) { 17 | console.log(`\n${CMD_FMT.Bright}running${CMD_FMT.Reset} in ${cwd} - ${cmd}\n`); 18 | } else { 19 | console.log(`\n${CMD_FMT.Bright}running${CMD_FMT.Reset} - ${cmd}\n`); 20 | } 21 | } 22 | 23 | const proc = exec(cmd, cwd); 24 | const stdout = await new Response(proc.stdout).text(); 25 | const stderr = await new Response(proc.stderr).text(); 26 | 27 | if (verboseness === Verboseness.VERBOSE) { 28 | if (stdout !== '') { 29 | console.log( 30 | stdout 31 | .split('\n') 32 | .map(x => `${CMD_FMT.FgGray}>${CMD_FMT.Reset} ${x}\n`) 33 | .join(''), 34 | ); 35 | } 36 | 37 | if (stderr !== '') { 38 | console.log( 39 | stderr 40 | .split('\n') 41 | .map(x => `${CMD_FMT.FgRed}>${CMD_FMT.Reset} ${x}\n`) 42 | .join(''), 43 | ); 44 | } 45 | } 46 | 47 | const exit = await proc.exited; 48 | 49 | if (verboseness === Verboseness.NORMAL || verboseness === Verboseness.VERBOSE) { 50 | if (exit === 0) { 51 | console.log(`${CMD_FMT.FgGreen}success${CMD_FMT.Reset} - ${cmd}\n`); 52 | } else { 53 | console.log(`${CMD_FMT.FgRed}fail${CMD_FMT.Reset} - ${cmd} - code ${exit}\n`); 54 | } 55 | } 56 | 57 | return { 58 | stdout, 59 | stderr, 60 | exit, 61 | }; 62 | } 63 | 64 | export async function $seq( 65 | cmds: string[], 66 | onError: (cmd: string, index: number) => void, 67 | onSuccess: () => void, 68 | cwd?: string | undefined, 69 | verboseness: Verboseness = Verboseness.NORMAL, 70 | ): Promise { 71 | const results = []; 72 | for (let i = 0; i < cmds.length; i += 1) { 73 | const cmd = cmds[i]; 74 | const result = await $(cmd, cwd, verboseness); 75 | 76 | if (result.exit !== 0) { 77 | onError(cmd, i); 78 | return; 79 | } 80 | 81 | results.push(result); 82 | } 83 | onSuccess(); 84 | } 85 | 86 | export async function $input(message: string): Promise { 87 | console.write(`${message} `); 88 | const stdin = Bun.stdin.stream(); 89 | const reader = stdin.getReader(); 90 | const chunk = await reader.read(); 91 | reader.releaseLock(); 92 | const text = Buffer.from(chunk.value ?? '').toString(); 93 | return text.trim(); 94 | } 95 | 96 | export async function $choice(message: string, options: string[]): Promise { 97 | console.log(`${message} `); 98 | 99 | let optionNumbers = new Map(); 100 | 101 | for (let i = 0; i < options.length; i++) { 102 | const option = options[i]; 103 | console.log(`[${i}] ${option}`); 104 | 105 | optionNumbers.set(i.toString(), i); 106 | } 107 | 108 | let ret: undefined | number = undefined; 109 | 110 | while (ret === undefined) { 111 | const selectedStr = await $input(`Select [${[...optionNumbers.keys()].join('/')}]:`); 112 | 113 | ret = optionNumbers.get(selectedStr); 114 | 115 | if (ret === undefined) { 116 | console.log(`${CMD_FMT.FgRed}invalid selection, please select a valid option${CMD_FMT.Reset}`); 117 | } 118 | } 119 | 120 | return ret; 121 | } 122 | 123 | export async function $confirm(message: string, onReject: () => void): Promise { 124 | while (true) { 125 | const answer = await $input(`${message} [Y/N]?`); 126 | 127 | if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') { 128 | return; 129 | } 130 | 131 | if (answer.toLowerCase() === 'n' || answer.toLowerCase() === 'no') { 132 | onReject(); 133 | return; 134 | } 135 | 136 | console.log(`${CMD_FMT.FgRed}invalid selection, please select a valid option${CMD_FMT.Reset}`); 137 | } 138 | } 139 | 140 | export const CMD_FMT = { 141 | Reset: '\x1b[0m', 142 | Bright: '\x1b[1m', 143 | Dim: '\x1b[2m', 144 | Underscore: '\x1b[4m', 145 | Blink: '\x1b[5m', 146 | Reverse: '\x1b[7m', 147 | Hidden: '\x1b[8m', 148 | 149 | FgBlack: '\x1b[30m', 150 | FgRed: '\x1b[31m', 151 | FgGreen: '\x1b[32m', 152 | FgYellow: '\x1b[33m', 153 | FgBlue: '\x1b[34m', 154 | FgMagenta: '\x1b[35m', 155 | FgCyan: '\x1b[36m', 156 | FgWhite: '\x1b[37m', 157 | FgGray: '\x1b[90m', 158 | 159 | BgBlack: '\x1b[40m', 160 | BgRed: '\x1b[41m', 161 | BgGreen: '\x1b[42m', 162 | BgYellow: '\x1b[43m', 163 | BgBlue: '\x1b[44m', 164 | BgMagenta: '\x1b[45m', 165 | BgCyan: '\x1b[46m', 166 | BgWhite: '\x1b[47m', 167 | BgGray: '\x1b[100m', 168 | }; 169 | -------------------------------------------------------------------------------- /automation/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export class UserError extends Error {} 2 | 3 | export interface ProjectConfig { 4 | corePackages: string[]; 5 | packages: string[]; 6 | } 7 | -------------------------------------------------------------------------------- /automation/utils/versionUtils.ts: -------------------------------------------------------------------------------- 1 | import { Parser } from '@lemons_dev/parsinom/lib/Parser'; 2 | import { P_UTILS } from '@lemons_dev/parsinom/lib/ParserUtils'; 3 | import { P } from '@lemons_dev/parsinom/lib/ParsiNOM'; 4 | import Moment from 'moment'; 5 | import { UserError } from 'utils/utils'; 6 | 7 | export class Version { 8 | major: number; 9 | minor: number; 10 | patch: number; 11 | 12 | constructor(major: number, minor: number, patch: number) { 13 | this.major = major; 14 | this.minor = minor; 15 | this.patch = patch; 16 | } 17 | 18 | toString(): string { 19 | return `${this.major}.${this.minor}.${this.patch}`; 20 | } 21 | } 22 | 23 | export class CanaryVersion extends Version { 24 | canary: string; 25 | 26 | constructor(major: number, minor: number, patch: number, canary: string) { 27 | super(major, minor, patch); 28 | this.canary = canary; 29 | } 30 | 31 | toString(): string { 32 | return `${super.toString()}-canary.${this.canary}`; 33 | } 34 | } 35 | 36 | const numberParser: Parser = P_UTILS.digits() 37 | .map(x => Number.parseInt(x)) 38 | .chain(x => { 39 | if (Number.isNaN(x)) { 40 | return P.fail('a number'); 41 | } else { 42 | return P.succeed(x); 43 | } 44 | }); 45 | 46 | const canaryParser: Parser = P.sequenceMap( 47 | (_, c1, c2, c3) => { 48 | return c1 + c2 + c3; 49 | }, 50 | P.string('-canary.'), 51 | P_UTILS.digit() 52 | .repeat(8, 8) 53 | .map(x => x.join('')), 54 | P.string('T'), 55 | P_UTILS.digit() 56 | .repeat(6, 6) 57 | .map(x => x.join('')), 58 | ); 59 | 60 | export const versionParser: Parser = P.or( 61 | P.sequenceMap( 62 | (major, _1, minor, _2, patch) => { 63 | return new Version(major, minor, patch); 64 | }, 65 | numberParser, 66 | P.string('.'), 67 | numberParser, 68 | P.string('.'), 69 | numberParser, 70 | P_UTILS.eof(), 71 | ), 72 | P.sequenceMap( 73 | (major, _1, minor, _2, patch, canary) => { 74 | return new CanaryVersion(major, minor, patch, canary); 75 | }, 76 | numberParser, 77 | P.string('.'), 78 | numberParser, 79 | P.string('.'), 80 | numberParser, 81 | canaryParser, 82 | P_UTILS.eof(), 83 | ), 84 | ); 85 | 86 | export function parseVersion(str: string): Version { 87 | const parserRes = versionParser.tryParse(str); 88 | if (parserRes.success) { 89 | return parserRes.value; 90 | } else { 91 | throw new UserError(`failed to parse manifest version "${str}"`); 92 | } 93 | } 94 | 95 | export function stringifyVersion(version: Version): string { 96 | return version.toString(); 97 | } 98 | 99 | export function getIncrementOptions(version: Version): [Version, Version, Version, CanaryVersion] { 100 | const moment = Moment(); 101 | const canary = moment.utcOffset(0).format('YYYYMMDDTHHmmss'); 102 | return [ 103 | new Version(version.major + 1, 0, 0), 104 | new Version(version.major, version.minor + 1, 0), 105 | new Version(version.major, version.minor, version.patch + 1), 106 | new CanaryVersion(version.major, version.minor, version.patch, canary), 107 | ]; 108 | } 109 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mProjectsCode/obsidian-media-db-plugin/db444c83f5154a3e46c4a2f0a3e4eaad9979f720/bun.lockb -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | import only_warn from 'eslint-plugin-only-warn'; 6 | import * as plugin_import from 'eslint-plugin-import'; 7 | 8 | export default tseslint.config( 9 | { 10 | ignores: ['npm/', 'node_modules/', 'exampleVault/', 'automation/', 'main.js', '*.svelte'], 11 | }, 12 | { 13 | files: ['src/**/*.ts'], 14 | extends: [eslint.configs.recommended, ...tseslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.stylisticTypeChecked], 15 | languageOptions: { 16 | parser: tseslint.parser, 17 | parserOptions: { 18 | project: true, 19 | }, 20 | }, 21 | plugins: { 22 | // @ts-ignore 23 | 'only-warn': only_warn, 24 | import: plugin_import, 25 | }, 26 | rules: { 27 | '@typescript-eslint/no-explicit-any': ['warn'], 28 | 29 | '@typescript-eslint/no-unused-vars': [ 30 | 'error', 31 | { argsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, 32 | ], 33 | '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports', fixStyle: 'separate-type-imports' }], 34 | 35 | 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], 36 | 'import/order': [ 37 | 'error', 38 | { 39 | 'newlines-between': 'never', 40 | alphabetize: { order: 'asc', orderImportKind: 'asc', caseInsensitive: true }, 41 | }, 42 | ], 43 | 44 | '@typescript-eslint/no-confusing-void-expression': ['error', { ignoreArrowShorthand: true }], 45 | '@typescript-eslint/restrict-template-expressions': 'off', 46 | 47 | '@typescript-eslint/ban-ts-comment': 'off', 48 | '@typescript-eslint/no-empty-function': 'off', 49 | '@typescript-eslint/no-inferrable-types': 'off', 50 | '@typescript-eslint/explicit-function-return-type': ['warn'], 51 | '@typescript-eslint/require-await': 'off', 52 | }, 53 | }, 54 | ); 55 | -------------------------------------------------------------------------------- /exampleVault/index.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mProjectsCode/obsidian-media-db-plugin/db444c83f5154a3e46c4a2f0a3e4eaad9979f720/exampleVault/index.md -------------------------------------------------------------------------------- /manifest-beta.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-media-db-plugin", 3 | "name": "Media DB", 4 | "version": "0.8.0", 5 | "minAppVersion": "1.5.0", 6 | "description": "A plugin that can query multiple APIs for movies, series, anime, games, music and wiki articles, and import them into your vault.", 7 | "author": "Moritz Jung", 8 | "authorUrl": "https://www.moritzjung.dev", 9 | "fundingUrl": "https://github.com/sponsors/mProjectsCode", 10 | "isDesktopOnly": false 11 | } 12 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-media-db-plugin", 3 | "name": "Media DB", 4 | "version": "0.8.0", 5 | "minAppVersion": "1.5.0", 6 | "description": "A plugin that can query multiple APIs for movies, series, anime, games, music and wiki articles, and import them into your vault.", 7 | "author": "Moritz Jung", 8 | "authorUrl": "https://www.moritzjung.dev", 9 | "fundingUrl": "https://github.com/sponsors/mProjectsCode", 10 | "isDesktopOnly": false 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-media-db-plugin", 3 | "version": "0.8.0", 4 | "description": "A plugin that can query multiple APIs for movies, series, anime, games, music and wiki articles, and import them into your vault.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "bun run automation/build/esbuild.dev.config.ts", 8 | "build": "bun run tsc && bun run automation/build/esbuild.config.ts", 9 | "tsc": "tsc -noEmit -skipLibCheck", 10 | "test": "bun test", 11 | "test:log": "LOG_TESTS=true bun test", 12 | "format": "prettier --write --plugin prettier-plugin-svelte .", 13 | "format:check": "prettier --check --plugin prettier-plugin-svelte .", 14 | "lint": "eslint --max-warnings=0 src/**", 15 | "lint:fix": "eslint --max-warnings=0 --fix src/**", 16 | "svelte-check": "svelte-check --compiler-warnings \"unused-export-let:ignore\"", 17 | "check": "bun run format:check && bun run tsc && bun run test", 18 | "check:fix": "bun run format && bun run tsc && bun run test", 19 | "release": "bun run automation/release.ts", 20 | "stats": "bun run automation/stats.ts" 21 | }, 22 | "keywords": [], 23 | "author": "Moritz Jung", 24 | "license": "GPL-3.0", 25 | "devDependencies": { 26 | "@popperjs/core": "^2.11.8", 27 | "@lemons_dev/parsinom": "^0.0.12", 28 | "@happy-dom/global-registrator": "^14.12.3", 29 | "@types/bun": "^1.1.16", 30 | "builtin-modules": "^4.0.0", 31 | "esbuild": "^0.24.2", 32 | "esbuild-plugin-copy-watch": "^2.3.1", 33 | "esbuild-svelte": "^0.8.2", 34 | "eslint": "^9.18.0", 35 | "eslint-plugin-import": "^2.31.0", 36 | "eslint-plugin-only-warn": "^1.1.0", 37 | "obsidian": "latest", 38 | "prettier": "^3.4.2", 39 | "prettier-plugin-svelte": "^3.3.3", 40 | "string-argv": "^0.3.2", 41 | "svelte": "^5.17.5", 42 | "svelte-check": "^4.1.4", 43 | "svelte-preprocess": "^6.0.3", 44 | "tslib": "^2.8.1", 45 | "typescript": "^5.7.3", 46 | "typescript-eslint": "^8.20.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/api/APIManager.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from 'obsidian'; 2 | import type { MediaTypeModel } from '../models/MediaTypeModel'; 3 | import type { APIModel } from './APIModel'; 4 | 5 | export class APIManager { 6 | apis: APIModel[]; 7 | 8 | constructor() { 9 | this.apis = []; 10 | } 11 | 12 | /** 13 | * Queries the basic info for one query string and multiple APIs. 14 | * 15 | * @param query 16 | * @param apisToQuery 17 | */ 18 | async query(query: string, apisToQuery: string[]): Promise { 19 | console.debug(`MDB | api manager queried with "${query}"`); 20 | 21 | const promises = this.apis 22 | .filter(api => apisToQuery.contains(api.apiName)) 23 | .map(async api => { 24 | try { 25 | return await api.searchByTitle(query); 26 | } catch (e) { 27 | new Notice(`Error querying ${api.apiName}: ${e}`); 28 | console.warn(e); 29 | 30 | return []; 31 | } 32 | }); 33 | 34 | return (await Promise.all(promises)).flat(); 35 | } 36 | 37 | /** 38 | * Queries detailed information for a MediaTypeModel. 39 | * 40 | * @param item 41 | */ 42 | async queryDetailedInfo(item: MediaTypeModel): Promise { 43 | return await this.queryDetailedInfoById(item.id, item.dataSource); 44 | } 45 | 46 | /** 47 | * Queries detailed info for an id from an API. 48 | * 49 | * @param id 50 | * @param apiName 51 | */ 52 | async queryDetailedInfoById(id: string, apiName: string): Promise { 53 | for (const api of this.apis) { 54 | if (api.apiName === apiName) { 55 | try { 56 | return api.getById(id); 57 | } catch (e) { 58 | new Notice(`Error querying ${api.apiName}: ${e}`); 59 | console.warn(e); 60 | 61 | return undefined; 62 | } 63 | } 64 | } 65 | 66 | return undefined; 67 | } 68 | 69 | getApiByName(name: string): APIModel | undefined { 70 | for (const api of this.apis) { 71 | if (api.apiName === name) { 72 | return api; 73 | } 74 | } 75 | 76 | return undefined; 77 | } 78 | 79 | registerAPI(api: APIModel): void { 80 | this.apis.push(api); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/api/APIModel.ts: -------------------------------------------------------------------------------- 1 | import type MediaDbPlugin from '../main'; 2 | import type { MediaTypeModel } from '../models/MediaTypeModel'; 3 | import type { MediaType } from '../utils/MediaType'; 4 | 5 | export abstract class APIModel { 6 | apiName!: string; 7 | apiUrl!: string; 8 | apiDescription!: string; 9 | types!: MediaType[]; 10 | plugin!: MediaDbPlugin; 11 | 12 | /** 13 | * This function should query the api and return a list of matches. The matches should be capped at 20. 14 | * 15 | * @param title the title to query for 16 | */ 17 | abstract searchByTitle(title: string): Promise; 18 | 19 | abstract getById(id: string): Promise; 20 | 21 | abstract getDisabledMediaTypes(): MediaType[]; 22 | 23 | hasType(type: MediaType): boolean { 24 | const disabledMediaTypes = this.getDisabledMediaTypes(); 25 | return this.types.includes(type) && !disabledMediaTypes.includes(type); 26 | } 27 | 28 | hasTypeOverlap(types: MediaType[]): boolean { 29 | return types.some(type => this.hasType(type)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/api/apis/BoardGameGeekAPI.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from 'obsidian'; 2 | import { BoardGameModel } from 'src/models/BoardGameModel'; 3 | import type MediaDbPlugin from '../../main'; 4 | import type { MediaTypeModel } from '../../models/MediaTypeModel'; 5 | import { MediaType } from '../../utils/MediaType'; 6 | import { APIModel } from '../APIModel'; 7 | 8 | export class BoardGameGeekAPI extends APIModel { 9 | plugin: MediaDbPlugin; 10 | 11 | constructor(plugin: MediaDbPlugin) { 12 | super(); 13 | 14 | this.plugin = plugin; 15 | this.apiName = 'BoardGameGeekAPI'; 16 | this.apiDescription = 'A free API for BoardGameGeek things.'; 17 | this.apiUrl = 'https://api.geekdo.com/xmlapi'; 18 | this.types = [MediaType.BoardGame]; 19 | } 20 | 21 | async searchByTitle(title: string): Promise { 22 | console.log(`MDB | api "${this.apiName}" queried by Title`); 23 | 24 | const searchUrl = `${this.apiUrl}/search?search=${encodeURIComponent(title)}`; 25 | const fetchData = await requestUrl({ 26 | url: searchUrl, 27 | }); 28 | 29 | if (fetchData.status !== 200) { 30 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 31 | } 32 | 33 | const data = fetchData.text; 34 | const response = new window.DOMParser().parseFromString(data, 'text/xml'); 35 | 36 | // console.debug(response); 37 | 38 | const ret: MediaTypeModel[] = []; 39 | 40 | for (const boardgame of Array.from(response.querySelectorAll('boardgame'))) { 41 | const id = boardgame.attributes.getNamedItem('objectid')?.value; 42 | const title = boardgame.querySelector('name[primary=true]')?.textContent ?? boardgame.querySelector('name')?.textContent ?? undefined; 43 | const year = boardgame.querySelector('yearpublished')?.textContent ?? ''; 44 | 45 | ret.push( 46 | new BoardGameModel({ 47 | dataSource: this.apiName, 48 | id, 49 | title, 50 | englishTitle: title, 51 | year, 52 | }), 53 | ); 54 | } 55 | 56 | return ret; 57 | } 58 | 59 | async getById(id: string): Promise { 60 | console.log(`MDB | api "${this.apiName}" queried by ID`); 61 | 62 | const searchUrl = `${this.apiUrl}/boardgame/${encodeURIComponent(id)}?stats=1`; 63 | const fetchData = await requestUrl({ 64 | url: searchUrl, 65 | }); 66 | 67 | if (fetchData.status !== 200) { 68 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 69 | } 70 | 71 | const data = fetchData.text; 72 | const response = new window.DOMParser().parseFromString(data, 'text/xml'); 73 | // console.debug(response); 74 | 75 | const boardgame = response.querySelector('boardgame'); 76 | if (!boardgame) { 77 | throw Error(`MDB | Received invalid data from ${this.apiName}.`); 78 | } 79 | 80 | const title = boardgame.querySelector('name[primary=true]')?.textContent; 81 | const year = boardgame.querySelector('yearpublished')?.textContent ?? ''; 82 | const image = boardgame.querySelector('image')?.textContent ?? undefined; 83 | const onlineRating = Number.parseFloat(boardgame.querySelector('statistics ratings average')?.textContent ?? '0'); 84 | const genres = Array.from(boardgame.querySelectorAll('boardgamecategory')) 85 | .map(n => n.textContent) 86 | .filter(n => n !== null); 87 | const complexityRating = Number.parseFloat(boardgame.querySelector('averageweight')?.textContent ?? '0'); 88 | const minPlayers = Number.parseFloat(boardgame.querySelector('minplayers')?.textContent ?? '0'); 89 | const maxPlayers = Number.parseFloat(boardgame.querySelector('maxplayers')?.textContent ?? '0'); 90 | const playtime = (boardgame.querySelector('playingtime')?.textContent ?? 'unknown') + ' minutes'; 91 | const publishers = Array.from(boardgame.querySelectorAll('boardgamepublisher')) 92 | .map(n => n.textContent) 93 | .filter(n => n !== null); 94 | 95 | return new BoardGameModel({ 96 | title: title ?? undefined, 97 | englishTitle: title ?? undefined, 98 | year: year === '0' ? '' : year, 99 | dataSource: this.apiName, 100 | url: `https://boardgamegeek.com/boardgame/${id}`, 101 | id: id, 102 | 103 | genres: genres, 104 | onlineRating: onlineRating, 105 | complexityRating: complexityRating, 106 | minPlayers: minPlayers, 107 | maxPlayers: maxPlayers, 108 | playtime: playtime, 109 | publishers: publishers, 110 | image: image, 111 | 112 | released: true, 113 | 114 | userData: { 115 | played: false, 116 | personalRating: 0, 117 | }, 118 | }); 119 | } 120 | getDisabledMediaTypes(): MediaType[] { 121 | return this.plugin.settings.BoardgameGeekAPI_disabledMediaTypes as MediaType[]; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/api/apis/ComicVineAPI.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from 'obsidian'; 2 | import { ComicMangaModel } from 'src/models/ComicMangaModel'; 3 | import type MediaDbPlugin from '../../main'; 4 | import type { MediaTypeModel } from '../../models/MediaTypeModel'; 5 | import { MediaType } from '../../utils/MediaType'; 6 | import { APIModel } from '../APIModel'; 7 | 8 | export class ComicVineAPI extends APIModel { 9 | plugin: MediaDbPlugin; 10 | 11 | constructor(plugin: MediaDbPlugin) { 12 | super(); 13 | 14 | this.plugin = plugin; 15 | this.apiName = 'ComicVineAPI'; 16 | this.apiDescription = 'A free API for comic books.'; 17 | this.apiUrl = 'https://comicvine.gamespot.com/api'; 18 | this.types = [MediaType.ComicManga]; 19 | } 20 | 21 | async searchByTitle(title: string): Promise { 22 | console.log(`MDB | api "${this.apiName}" queried by Title`); 23 | 24 | const searchUrl = `${this.apiUrl}/search/?api_key=${this.plugin.settings.ComicVineKey}&format=json&resources=volume&query=${encodeURIComponent(title)}`; 25 | const fetchData = await requestUrl({ 26 | url: searchUrl, 27 | }); 28 | // console.debug(fetchData); 29 | if (fetchData.status !== 200) { 30 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 31 | } 32 | 33 | const data = await fetchData.json; 34 | // console.debug(data); 35 | const ret: MediaTypeModel[] = []; 36 | for (const result of data.results) { 37 | ret.push( 38 | new ComicMangaModel({ 39 | title: result.name, 40 | englishTitle: result.name, 41 | year: result.start_year, 42 | dataSource: this.apiName, 43 | id: `4050-${result.id}`, 44 | publishers: result.publisher?.name ?? [], 45 | }), 46 | ); 47 | } 48 | 49 | return ret; 50 | } 51 | 52 | async getById(id: string): Promise { 53 | console.log(`MDB | api "${this.apiName}" queried by ID`); 54 | 55 | const searchUrl = `${this.apiUrl}/volume/${encodeURIComponent(id)}/?api_key=${this.plugin.settings.ComicVineKey}&format=json`; 56 | const fetchData = await requestUrl({ 57 | url: searchUrl, 58 | }); 59 | 60 | console.debug(fetchData); 61 | 62 | if (fetchData.status !== 200) { 63 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 64 | } 65 | 66 | const data = await fetchData.json; 67 | // console.debug(data); 68 | const result = data.results; 69 | 70 | return new ComicMangaModel({ 71 | type: MediaType.ComicManga, 72 | title: result.name, 73 | englishTitle: result.name, 74 | alternateTitles: result.aliases, 75 | plot: result.deck, 76 | year: result.start_year ?? '', 77 | dataSource: this.apiName, 78 | url: result.site_detail_url, 79 | id: `4050-${result.id}`, 80 | 81 | authors: result.people?.map((x: any) => x.name) ?? [], 82 | chapters: result.count_of_issues, 83 | image: result.image?.original_url ?? '', 84 | 85 | released: true, 86 | publishers: result.publisher?.name ?? [], 87 | publishedFrom: result.start_year ?? 'unknown', 88 | publishedTo: 'unknown', 89 | status: result.status, 90 | 91 | userData: { 92 | read: false, 93 | lastRead: '', 94 | personalRating: 0, 95 | }, 96 | }); 97 | } 98 | getDisabledMediaTypes(): MediaType[] { 99 | return this.plugin.settings.ComicVineAPI_disabledMediaTypes as MediaType[]; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/api/apis/GiantBombAPI.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from 'obsidian'; 2 | import type MediaDbPlugin from '../../main'; 3 | import { GameModel } from '../../models/GameModel'; 4 | import type { MediaTypeModel } from '../../models/MediaTypeModel'; 5 | import { MediaType } from '../../utils/MediaType'; 6 | import { APIModel } from '../APIModel'; 7 | 8 | export class GiantBombAPI extends APIModel { 9 | plugin: MediaDbPlugin; 10 | apiDateFormat: string = 'YYYY-MM-DD'; 11 | 12 | constructor(plugin: MediaDbPlugin) { 13 | super(); 14 | 15 | this.plugin = plugin; 16 | this.apiName = 'GiantBombAPI'; 17 | this.apiDescription = 'A free API for games.'; 18 | this.apiUrl = 'https://www.giantbomb.com/api'; 19 | this.types = [MediaType.Game]; 20 | } 21 | async searchByTitle(title: string): Promise { 22 | console.log(`MDB | api "${this.apiName}" queried by Title`); 23 | 24 | if (!this.plugin.settings.GiantBombKey) { 25 | throw Error(`MDB | API key for ${this.apiName} missing.`); 26 | } 27 | 28 | const searchUrl = `${this.apiUrl}/games?api_key=${this.plugin.settings.GiantBombKey}&filter=name:${encodeURIComponent(title)}&format=json`; 29 | const fetchData = await requestUrl({ 30 | url: searchUrl, 31 | }); 32 | 33 | // console.debug(fetchData); 34 | 35 | if (fetchData.status === 401) { 36 | throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); 37 | } 38 | if (fetchData.status === 429) { 39 | throw Error(`MDB | Too many requests for ${this.apiName}, you've exceeded your API quota.`); 40 | } 41 | if (fetchData.status !== 200) { 42 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 43 | } 44 | 45 | const data = await fetchData.json; 46 | // console.debug(data); 47 | const ret: MediaTypeModel[] = []; 48 | for (const result of data.results) { 49 | ret.push( 50 | new GameModel({ 51 | type: MediaType.Game, 52 | title: result.name, 53 | englishTitle: result.name, 54 | year: new Date(result.original_release_date).getFullYear().toString(), 55 | dataSource: this.apiName, 56 | id: result.guid, 57 | }), 58 | ); 59 | } 60 | 61 | return ret; 62 | } 63 | 64 | async getById(id: string): Promise { 65 | console.log(`MDB | api "${this.apiName}" queried by ID`); 66 | 67 | if (!this.plugin.settings.GiantBombKey) { 68 | throw Error(`MDB | API key for ${this.apiName} missing.`); 69 | } 70 | 71 | const searchUrl = `${this.apiUrl}/game/${encodeURIComponent(id)}/?api_key=${this.plugin.settings.GiantBombKey}&format=json`; 72 | const fetchData = await requestUrl({ 73 | url: searchUrl, 74 | }); 75 | console.debug(fetchData); 76 | 77 | if (fetchData.status !== 200) { 78 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 79 | } 80 | 81 | const data = await fetchData.json; 82 | // console.debug(data); 83 | const result = data.results; 84 | 85 | return new GameModel({ 86 | type: MediaType.Game, 87 | title: result.name, 88 | englishTitle: result.name, 89 | year: new Date(result.original_release_date).getFullYear().toString(), 90 | dataSource: this.apiName, 91 | url: result.site_detail_url, 92 | id: result.guid, 93 | developers: result.developers?.map((x: any) => x.name) ?? [], 94 | publishers: result.publishers?.map((x: any) => x.name) ?? [], 95 | genres: result.genres?.map((x: any) => x.name) ?? [], 96 | onlineRating: 0, 97 | image: result.image?.super_url ?? '', 98 | 99 | released: true, 100 | releaseDate: result.original_release_date ?? 'unknown', 101 | 102 | userData: { 103 | played: false, 104 | 105 | personalRating: 0, 106 | }, 107 | }); 108 | } 109 | getDisabledMediaTypes(): MediaType[] { 110 | return this.plugin.settings.GiantBombAPI_disabledMediaTypes as MediaType[]; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/api/apis/MALAPI.ts: -------------------------------------------------------------------------------- 1 | import type MediaDbPlugin from '../../main'; 2 | import type { MediaTypeModel } from '../../models/MediaTypeModel'; 3 | import { MovieModel } from '../../models/MovieModel'; 4 | import { SeriesModel } from '../../models/SeriesModel'; 5 | import { MediaType } from '../../utils/MediaType'; 6 | import { APIModel } from '../APIModel'; 7 | 8 | export class MALAPI extends APIModel { 9 | plugin: MediaDbPlugin; 10 | typeMappings: Map; 11 | apiDateFormat: string = 'YYYY-MM-DDTHH:mm:ssZ'; // ISO 12 | 13 | constructor(plugin: MediaDbPlugin) { 14 | super(); 15 | 16 | this.plugin = plugin; 17 | this.apiName = 'MALAPI'; 18 | this.apiDescription = 'A free API for Anime. Some results may take a long time to load.'; 19 | this.apiUrl = 'https://jikan.moe/'; 20 | this.types = [MediaType.Movie, MediaType.Series]; 21 | this.typeMappings = new Map(); 22 | this.typeMappings.set('movie', 'movie'); 23 | this.typeMappings.set('special', 'special'); 24 | this.typeMappings.set('tv', 'series'); 25 | this.typeMappings.set('ova', 'ova'); 26 | } 27 | 28 | async searchByTitle(title: string): Promise { 29 | console.log(`MDB | api "${this.apiName}" queried by Title`); 30 | 31 | const searchUrl = `https://api.jikan.moe/v4/anime?q=${encodeURIComponent(title)}&limit=20${this.plugin.settings.sfwFilter ? '&sfw' : ''}`; 32 | 33 | const fetchData = await fetch(searchUrl); 34 | // console.debug(fetchData); 35 | if (fetchData.status !== 200) { 36 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 37 | } 38 | const data = await fetchData.json(); 39 | 40 | // console.debug(data); 41 | 42 | const ret: MediaTypeModel[] = []; 43 | 44 | for (const result of data.data) { 45 | const type = this.typeMappings.get(result.type?.toLowerCase()); 46 | if (type === undefined) { 47 | ret.push( 48 | new MovieModel({ 49 | subType: '', 50 | title: result.title, 51 | englishTitle: result.title_english ?? result.title, 52 | year: result.year ?? result.aired?.prop?.from?.year ?? '', 53 | dataSource: this.apiName, 54 | id: result.mal_id, 55 | }), 56 | ); 57 | } 58 | if (type === 'movie' || type === 'special') { 59 | ret.push( 60 | new MovieModel({ 61 | subType: type, 62 | title: result.title, 63 | englishTitle: result.title_english ?? result.title, 64 | year: result.year ?? result.aired?.prop?.from?.year ?? '', 65 | dataSource: this.apiName, 66 | id: result.mal_id, 67 | }), 68 | ); 69 | } else if (type === 'series' || type === 'ova') { 70 | ret.push( 71 | new SeriesModel({ 72 | subType: type, 73 | title: result.title, 74 | englishTitle: result.title_english ?? result.title, 75 | year: result.year ?? result.aired?.prop?.from?.year ?? '', 76 | dataSource: this.apiName, 77 | id: result.mal_id, 78 | }), 79 | ); 80 | } 81 | } 82 | 83 | return ret; 84 | } 85 | 86 | async getById(id: string): Promise { 87 | console.log(`MDB | api "${this.apiName}" queried by ID`); 88 | 89 | const searchUrl = `https://api.jikan.moe/v4/anime/${encodeURIComponent(id)}/full`; 90 | const fetchData = await fetch(searchUrl); 91 | 92 | if (fetchData.status !== 200) { 93 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 94 | } 95 | 96 | const data = await fetchData.json(); 97 | // console.debug(data); 98 | const result = data.data; 99 | 100 | const type = this.typeMappings.get(result.type?.toLowerCase()); 101 | if (type === undefined) { 102 | return new MovieModel({ 103 | subType: '', 104 | title: result.title, 105 | englishTitle: result.title_english ?? result.title, 106 | year: result.year ?? result.aired?.prop?.from?.year ?? '', 107 | dataSource: this.apiName, 108 | url: result.url, 109 | id: result.mal_id, 110 | 111 | plot: result.synopsis, 112 | genres: result.genres?.map((x: any) => x.name) ?? [], 113 | director: [], 114 | writer: [], 115 | studio: result.studios?.map((x: any) => x.name).join(', ') ?? 'unknown', 116 | duration: result.duration ?? 'unknown', 117 | onlineRating: result.score ?? 0, 118 | actors: [], 119 | image: result.images?.jpg?.image_url ?? '', 120 | 121 | released: true, 122 | premiere: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat) ?? 'unknown', 123 | streamingServices: result.streaming?.map((x: any) => x.name) ?? [], 124 | 125 | userData: { 126 | watched: false, 127 | lastWatched: '', 128 | personalRating: 0, 129 | }, 130 | }); 131 | } 132 | 133 | if (type === 'movie' || type === 'special') { 134 | return new MovieModel({ 135 | subType: type, 136 | title: result.title, 137 | englishTitle: result.title_english ?? result.title, 138 | year: result.year ?? result.aired?.prop?.from?.year ?? '', 139 | dataSource: this.apiName, 140 | url: result.url, 141 | id: result.mal_id, 142 | 143 | plot: result.synopsis, 144 | genres: result.genres?.map((x: any) => x.name) ?? [], 145 | director: [], 146 | writer: [], 147 | studio: result.studios?.map((x: any) => x.name).join(', ') ?? 'unknown', 148 | duration: result.duration ?? 'unknown', 149 | onlineRating: result.score ?? 0, 150 | actors: [], 151 | image: result.images?.jpg?.image_url ?? '', 152 | 153 | released: true, 154 | premiere: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat) ?? 'unknown', 155 | streamingServices: result.streaming?.map((x: any) => x.name) ?? [], 156 | 157 | userData: { 158 | watched: false, 159 | lastWatched: '', 160 | personalRating: 0, 161 | }, 162 | }); 163 | } else if (type === 'series' || type === 'ova') { 164 | return new SeriesModel({ 165 | subType: type, 166 | title: result.title, 167 | englishTitle: result.title_english ?? result.title, 168 | year: result.year ?? result.aired?.prop?.from?.year ?? '', 169 | dataSource: this.apiName, 170 | url: result.url, 171 | id: result.mal_id, 172 | 173 | plot: result.synopsis, 174 | genres: result.genres?.map((x: any) => x.name) ?? [], 175 | writer: [], 176 | studio: result.studios?.map((x: any) => x.name) ?? [], 177 | episodes: result.episodes, 178 | duration: result.duration ?? 'unknown', 179 | onlineRating: result.score ?? 0, 180 | streamingServices: result.streaming?.map((x: any) => x.name) ?? [], 181 | image: result.images?.jpg?.image_url ?? '', 182 | 183 | released: true, 184 | airedFrom: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat) ?? 'unknown', 185 | airedTo: this.plugin.dateFormatter.format(result.aired?.to, this.apiDateFormat) ?? 'unknown', 186 | airing: result.airing, 187 | 188 | userData: { 189 | watched: false, 190 | lastWatched: '', 191 | personalRating: 0, 192 | }, 193 | }); 194 | } 195 | 196 | throw new Error(`MDB | Unknown media type for id ${id}`); 197 | } 198 | getDisabledMediaTypes(): MediaType[] { 199 | return this.plugin.settings.MALAPI_disabledMediaTypes as MediaType[]; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/api/apis/MALAPIManga.ts: -------------------------------------------------------------------------------- 1 | import type MediaDbPlugin from '../../main'; 2 | import { ComicMangaModel } from '../../models/ComicMangaModel'; 3 | import type { MediaTypeModel } from '../../models/MediaTypeModel'; 4 | import { MediaType } from '../../utils/MediaType'; 5 | import { APIModel } from '../APIModel'; 6 | 7 | export class MALAPIManga extends APIModel { 8 | plugin: MediaDbPlugin; 9 | typeMappings: Map; 10 | 11 | constructor(plugin: MediaDbPlugin) { 12 | super(); 13 | 14 | this.plugin = plugin; 15 | this.apiName = 'MALAPI Manga'; 16 | this.apiDescription = 'A free API for Manga. Some results may take a long time to load.'; 17 | this.apiUrl = 'https://jikan.moe/'; 18 | this.types = [MediaType.ComicManga]; 19 | this.typeMappings = new Map(); 20 | this.typeMappings.set('manga', 'manga'); 21 | this.typeMappings.set('manhwa', 'manhwa'); 22 | this.typeMappings.set('doujinshi', 'doujin'); 23 | this.typeMappings.set('one-shot', 'oneshot'); 24 | this.typeMappings.set('manhua', 'manhua'); 25 | this.typeMappings.set('light novel', 'light-novel'); 26 | this.typeMappings.set('novel', 'novel'); 27 | } 28 | 29 | async searchByTitle(title: string): Promise { 30 | console.log(`MDB | api "${this.apiName}" queried by Title`); 31 | 32 | const searchUrl = `https://api.jikan.moe/v4/manga?q=${encodeURIComponent(title)}&limit=20${this.plugin.settings.sfwFilter ? '&sfw' : ''}`; 33 | 34 | const fetchData = await fetch(searchUrl); 35 | // console.debug(fetchData); 36 | if (fetchData.status !== 200) { 37 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 38 | } 39 | const data = await fetchData.json(); 40 | 41 | // console.debug(data); 42 | 43 | const ret: MediaTypeModel[] = []; 44 | 45 | for (const result of data.data) { 46 | const type = this.typeMappings.get(result.type?.toLowerCase()); 47 | ret.push( 48 | new ComicMangaModel({ 49 | subType: type, 50 | title: result.title, 51 | plot: result.synopsis, 52 | englishTitle: result.title_english ?? result.title, 53 | alternateTitles: result.titles?.map((x: any) => x.title) ?? [], 54 | year: result.year ?? result.published?.prop?.from?.year ?? '', 55 | dataSource: this.apiName, 56 | url: result.url, 57 | id: result.mal_id, 58 | 59 | genres: result.genres?.map((x: any) => x.name) ?? [], 60 | authors: result.authors?.map((x: any) => x.name) ?? [], 61 | chapters: result.chapters, 62 | volumes: result.volumes, 63 | onlineRating: result.score ?? 0, 64 | image: result.images?.jpg?.image_url ?? '', 65 | 66 | released: true, 67 | publishedFrom: new Date(result.published?.from).toLocaleDateString() ?? 'unknown', 68 | publishedTo: new Date(result.published?.to).toLocaleDateString() ?? 'unknown', 69 | status: result.status, 70 | 71 | userData: { 72 | read: false, 73 | lastRead: '', 74 | personalRating: 0, 75 | }, 76 | }), 77 | ); 78 | } 79 | 80 | return ret; 81 | } 82 | 83 | async getById(id: string): Promise { 84 | console.log(`MDB | api "${this.apiName}" queried by ID`); 85 | 86 | const searchUrl = `https://api.jikan.moe/v4/manga/${encodeURIComponent(id)}/full`; 87 | const fetchData = await fetch(searchUrl); 88 | 89 | if (fetchData.status !== 200) { 90 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 91 | } 92 | 93 | const data = await fetchData.json(); 94 | // console.debug(data); 95 | const result = data.data; 96 | 97 | const type = this.typeMappings.get(result.type?.toLowerCase()); 98 | return new ComicMangaModel({ 99 | subType: type, 100 | title: result.title, 101 | englishTitle: result.title_english ?? result.title, 102 | alternateTitles: result.titles?.map((x: any) => x.title) ?? [], 103 | year: result.year ?? result.published?.prop?.from?.year ?? '', 104 | dataSource: this.apiName, 105 | url: result.url, 106 | id: result.mal_id, 107 | 108 | plot: (result.synopsis ?? 'unknown').replace(/"/g, "'") ?? 'unknown', 109 | genres: result.genres?.map((x: any) => x.name) ?? [], 110 | authors: result.authors?.map((x: any) => x.name) ?? [], 111 | chapters: result.chapters, 112 | volumes: result.volumes, 113 | onlineRating: result.score ?? 0, 114 | image: result.images?.jpg?.image_url ?? '', 115 | 116 | released: true, 117 | publishers: result.serializations?.map((x: any) => x.name) ?? [], 118 | publishedFrom: new Date(result.published?.from).toLocaleDateString() ?? 'unknown', 119 | publishedTo: new Date(result.published?.to).toLocaleDateString() ?? 'unknown', 120 | status: result.status, 121 | 122 | userData: { 123 | read: false, 124 | lastRead: '', 125 | personalRating: 0, 126 | }, 127 | }); 128 | } 129 | getDisabledMediaTypes(): MediaType[] { 130 | return this.plugin.settings.MALAPIManga_disabledMediaTypes as MediaType[]; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/api/apis/MobyGamesAPI.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from 'obsidian'; 2 | import { requestUrl } from 'obsidian'; 3 | import type MediaDbPlugin from '../../main'; 4 | import { GameModel } from '../../models/GameModel'; 5 | import type { MediaTypeModel } from '../../models/MediaTypeModel'; 6 | import { MediaType } from '../../utils/MediaType'; 7 | import { APIModel } from '../APIModel'; 8 | 9 | export class MobyGamesAPI extends APIModel { 10 | plugin: MediaDbPlugin; 11 | apiDateFormat: string = 'YYYY-DD-MM'; 12 | 13 | constructor(plugin: MediaDbPlugin) { 14 | super(); 15 | 16 | this.plugin = plugin; 17 | this.apiName = 'MobyGamesAPI'; 18 | this.apiDescription = 'A free API for games.'; 19 | this.apiUrl = 'https://api.mobygames.com/v1'; 20 | this.types = [MediaType.Game]; 21 | } 22 | async searchByTitle(title: string): Promise { 23 | console.log(`MDB | api "${this.apiName}" queried by Title`); 24 | 25 | if (!this.plugin.settings.MobyGamesKey) { 26 | throw new Error(`MDB | API key for ${this.apiName} missing.`); 27 | } 28 | 29 | const searchUrl = `${this.apiUrl}/games?title=${encodeURIComponent(title)}&api_key=${this.plugin.settings.MobyGamesKey}`; 30 | const fetchData = await requestUrl({ 31 | url: searchUrl, 32 | }); 33 | 34 | // console.debug(fetchData); 35 | 36 | if (fetchData.status === 401) { 37 | throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); 38 | } 39 | if (fetchData.status === 429) { 40 | throw Error(`MDB | Too many requests for ${this.apiName}, you've exceeded your API quota.`); 41 | } 42 | if (fetchData.status !== 200) { 43 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 44 | } 45 | 46 | const data = await fetchData.json; 47 | // console.debug(data); 48 | const ret: MediaTypeModel[] = []; 49 | for (const result of data.games) { 50 | ret.push( 51 | new GameModel({ 52 | type: MediaType.Game, 53 | title: result.title, 54 | englishTitle: result.title, 55 | year: new Date(result.platforms[0].first_release_date).getFullYear().toString(), 56 | dataSource: this.apiName, 57 | id: result.game_id, 58 | } as GameModel), 59 | ); 60 | } 61 | 62 | return ret; 63 | } 64 | 65 | async getById(id: string): Promise { 66 | console.log(`MDB | api "${this.apiName}" queried by ID`); 67 | 68 | if (!this.plugin.settings.MobyGamesKey) { 69 | throw Error(`MDB | API key for ${this.apiName} missing.`); 70 | } 71 | 72 | const searchUrl = `${this.apiUrl}/games?id=${encodeURIComponent(id)}&api_key=${this.plugin.settings.MobyGamesKey}`; 73 | const fetchData = await requestUrl({ 74 | url: searchUrl, 75 | }); 76 | console.debug(fetchData); 77 | 78 | if (fetchData.status !== 200) { 79 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 80 | } 81 | 82 | const data = await fetchData.json; 83 | // console.debug(data); 84 | const result = data.games[0]; 85 | 86 | return new GameModel({ 87 | type: MediaType.Game, 88 | title: result.title, 89 | englishTitle: result.title, 90 | year: new Date(result.platforms[0].first_release_date).getFullYear().toString(), 91 | dataSource: this.apiName, 92 | url: `https://www.mobygames.com/game/${result.game_id}`, 93 | id: result.game_id, 94 | developers: [], 95 | publishers: [], 96 | genres: result.genres?.map((x: any) => x.genre_name) ?? [], 97 | onlineRating: result.moby_score, 98 | image: result.sample_cover?.image ?? '', 99 | 100 | released: true, 101 | releaseDate: result.platforms[0].first_release_date ?? 'unknown', 102 | 103 | userData: { 104 | played: false, 105 | 106 | personalRating: 0, 107 | }, 108 | }); 109 | } 110 | getDisabledMediaTypes(): MediaType[] { 111 | return this.plugin.settings.MobyGamesAPI_disabledMediaTypes as MediaType[]; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/api/apis/MusicBrainzAPI.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from 'obsidian'; 2 | import type MediaDbPlugin from '../../main'; 3 | import type { MediaTypeModel } from '../../models/MediaTypeModel'; 4 | import { MusicReleaseModel } from '../../models/MusicReleaseModel'; 5 | import { MediaType } from '../../utils/MediaType'; 6 | import { contactEmail, mediaDbVersion, pluginName } from '../../utils/Utils'; 7 | import { APIModel } from '../APIModel'; 8 | 9 | export class MusicBrainzAPI extends APIModel { 10 | plugin: MediaDbPlugin; 11 | 12 | constructor(plugin: MediaDbPlugin) { 13 | super(); 14 | 15 | this.plugin = plugin; 16 | this.apiName = 'MusicBrainz API'; 17 | this.apiDescription = 'Free API for music albums.'; 18 | this.apiUrl = 'https://musicbrainz.org/'; 19 | this.types = [MediaType.MusicRelease]; 20 | } 21 | 22 | async searchByTitle(title: string): Promise { 23 | console.log(`MDB | api "${this.apiName}" queried by Title`); 24 | 25 | const searchUrl = `https://musicbrainz.org/ws/2/release-group?query=${encodeURIComponent(title)}&limit=20&fmt=json`; 26 | 27 | const fetchData = await requestUrl({ 28 | url: searchUrl, 29 | headers: { 30 | 'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`, 31 | }, 32 | }); 33 | 34 | // console.debug(fetchData); 35 | 36 | if (fetchData.status !== 200) { 37 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 38 | } 39 | 40 | const data = await fetchData.json; 41 | // console.debug(data); 42 | const ret: MediaTypeModel[] = []; 43 | 44 | for (const result of data['release-groups']) { 45 | ret.push( 46 | new MusicReleaseModel({ 47 | type: 'musicRelease', 48 | title: result.title, 49 | englishTitle: result.title, 50 | year: new Date(result['first-release-date']).getFullYear().toString(), 51 | dataSource: this.apiName, 52 | url: 'https://musicbrainz.org/release-group/' + result.id, 53 | id: result.id, 54 | image: 'https://coverartarchive.org/release-group/' + result.id + '/front', 55 | 56 | artists: result['artist-credit'].map((a: any) => a.name), 57 | subType: result['primary-type'], 58 | }), 59 | ); 60 | } 61 | 62 | return ret; 63 | } 64 | 65 | async getById(id: string): Promise { 66 | console.log(`MDB | api "${this.apiName}" queried by ID`); 67 | 68 | const searchUrl = `https://musicbrainz.org/ws/2/release-group/${encodeURIComponent(id)}?inc=releases+artists+tags+ratings+genres&fmt=json`; 69 | const fetchData = await requestUrl({ 70 | url: searchUrl, 71 | headers: { 72 | 'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`, 73 | }, 74 | }); 75 | 76 | if (fetchData.status !== 200) { 77 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 78 | } 79 | 80 | const result = await fetchData.json; 81 | 82 | return new MusicReleaseModel({ 83 | type: 'musicRelease', 84 | title: result.title, 85 | englishTitle: result.title, 86 | year: new Date(result['first-release-date']).getFullYear().toString(), 87 | dataSource: this.apiName, 88 | url: 'https://musicbrainz.org/release-group/' + result.id, 89 | id: result.id, 90 | image: 'https://coverartarchive.org/release-group/' + result.id + '/front', 91 | 92 | artists: result['artist-credit'].map((a: any) => a.name), 93 | genres: result.genres.map((g: any) => g.name), 94 | subType: result['primary-type'], 95 | rating: result.rating.value * 2, 96 | 97 | userData: { 98 | personalRating: 0, 99 | }, 100 | }); 101 | } 102 | getDisabledMediaTypes(): MediaType[] { 103 | return this.plugin.settings.MusicBrainzAPI_disabledMediaTypes as MediaType[]; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/api/apis/OMDbAPI.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from 'obsidian'; 2 | import type MediaDbPlugin from '../../main'; 3 | import { GameModel } from '../../models/GameModel'; 4 | import type { MediaTypeModel } from '../../models/MediaTypeModel'; 5 | import { MovieModel } from '../../models/MovieModel'; 6 | import { SeriesModel } from '../../models/SeriesModel'; 7 | import { MediaType } from '../../utils/MediaType'; 8 | import { APIModel } from '../APIModel'; 9 | 10 | export class OMDbAPI extends APIModel { 11 | plugin: MediaDbPlugin; 12 | typeMappings: Map; 13 | apiDateFormat: string = 'DD MMM YYYY'; 14 | 15 | constructor(plugin: MediaDbPlugin) { 16 | super(); 17 | 18 | this.plugin = plugin; 19 | this.apiName = 'OMDbAPI'; 20 | this.apiDescription = 'A free API for Movies, Series and Games.'; 21 | this.apiUrl = 'https://www.omdbapi.com/'; 22 | this.types = [MediaType.Movie, MediaType.Series, MediaType.Game]; 23 | this.typeMappings = new Map(); 24 | this.typeMappings.set('movie', 'movie'); 25 | this.typeMappings.set('series', 'series'); 26 | this.typeMappings.set('game', 'game'); 27 | } 28 | 29 | async searchByTitle(title: string): Promise { 30 | console.log(`MDB | api "${this.apiName}" queried by Title`); 31 | 32 | if (!this.plugin.settings.OMDbKey) { 33 | throw new Error(`MDB | API key for ${this.apiName} missing.`); 34 | } 35 | 36 | const searchUrl = `https://www.omdbapi.com/?s=${encodeURIComponent(title)}&apikey=${this.plugin.settings.OMDbKey}`; 37 | const fetchData = await fetch(searchUrl); 38 | 39 | if (fetchData.status === 401) { 40 | throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); 41 | } 42 | if (fetchData.status !== 200) { 43 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 44 | } 45 | 46 | const data = await fetchData.json(); 47 | 48 | if (data.Response === 'False') { 49 | if (data.Error === 'Movie not found!') { 50 | return []; 51 | } 52 | 53 | throw Error(`MDB | Received error from ${this.apiName}: \n${JSON.stringify(data, undefined, 4)}`); 54 | } 55 | if (!data.Search) { 56 | return []; 57 | } 58 | 59 | // console.debug(data.Search); 60 | 61 | const ret: MediaTypeModel[] = []; 62 | 63 | for (const result of data.Search) { 64 | const type = this.typeMappings.get(result.Type.toLowerCase()); 65 | if (type === undefined) { 66 | continue; 67 | } 68 | if (type === 'movie') { 69 | ret.push( 70 | new MovieModel({ 71 | type: type, 72 | title: result.Title, 73 | englishTitle: result.Title, 74 | year: result.Year, 75 | dataSource: this.apiName, 76 | id: result.imdbID, 77 | }), 78 | ); 79 | } else if (type === 'series') { 80 | ret.push( 81 | new SeriesModel({ 82 | type: type, 83 | title: result.Title, 84 | englishTitle: result.Title, 85 | year: result.Year, 86 | dataSource: this.apiName, 87 | id: result.imdbID, 88 | }), 89 | ); 90 | } else if (type === 'game') { 91 | ret.push( 92 | new GameModel({ 93 | type: type, 94 | title: result.Title, 95 | englishTitle: result.Title, 96 | year: result.Year, 97 | dataSource: this.apiName, 98 | id: result.imdbID, 99 | }), 100 | ); 101 | } 102 | } 103 | 104 | return ret; 105 | } 106 | 107 | async getById(id: string): Promise { 108 | console.log(`MDB | api "${this.apiName}" queried by ID`); 109 | 110 | if (!this.plugin.settings.OMDbKey) { 111 | throw Error(`MDB | API key for ${this.apiName} missing.`); 112 | } 113 | 114 | const searchUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(id)}&apikey=${this.plugin.settings.OMDbKey}`; 115 | const fetchData = await fetch(searchUrl); 116 | 117 | if (fetchData.status === 401) { 118 | throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); 119 | } 120 | if (fetchData.status !== 200) { 121 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 122 | } 123 | 124 | const result = await fetchData.json(); 125 | // console.debug(result); 126 | 127 | if (result.Response === 'False') { 128 | throw Error(`MDB | Received error from ${this.apiName}: ${result.Error}`); 129 | } 130 | 131 | const type = this.typeMappings.get(result.Type.toLowerCase()); 132 | if (type === undefined) { 133 | throw Error(`${result.type.toLowerCase()} is an unsupported type.`); 134 | } 135 | 136 | if (type === 'movie') { 137 | return new MovieModel({ 138 | type: type, 139 | title: result.Title, 140 | englishTitle: result.Title, 141 | year: result.Year, 142 | dataSource: this.apiName, 143 | url: `https://www.imdb.com/title/${result.imdbID}/`, 144 | id: result.imdbID, 145 | 146 | plot: result.Plot ?? '', 147 | genres: result.Genre?.split(', ') ?? [], 148 | director: result.Director?.split(', ') ?? [], 149 | writer: result.Writer?.split(', ') ?? [], 150 | studio: ['N/A'], 151 | duration: result.Runtime ?? 'unknown', 152 | onlineRating: Number.parseFloat(result.imdbRating ?? 0), 153 | actors: result.Actors?.split(', ') ?? [], 154 | image: result.Poster ? result.Poster.replace('_SX300', '_SX600') : '', 155 | 156 | released: true, 157 | streamingServices: [], 158 | premiere: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat) ?? 'unknown', 159 | 160 | userData: { 161 | watched: false, 162 | lastWatched: '', 163 | personalRating: 0, 164 | }, 165 | }); 166 | } else if (type === 'series') { 167 | return new SeriesModel({ 168 | type: type, 169 | title: result.Title, 170 | englishTitle: result.Title, 171 | year: result.Year, 172 | dataSource: this.apiName, 173 | url: `https://www.imdb.com/title/${result.imdbID}/`, 174 | id: result.imdbID, 175 | 176 | plot: result.Plot ?? '', 177 | genres: result.Genre?.split(', ') ?? [], 178 | writer: result.Writer?.split(', ') ?? [], 179 | studio: [], 180 | episodes: 0, 181 | duration: result.Runtime ?? 'unknown', 182 | onlineRating: Number.parseFloat(result.imdbRating ?? 0), 183 | actors: result.Actors?.split(', ') ?? [], 184 | image: result.Poster ? result.Poster.replace('_SX300', '_SX600') : '', 185 | 186 | released: true, 187 | streamingServices: [], 188 | airing: false, 189 | airedFrom: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat) ?? 'unknown', 190 | airedTo: 'unknown', 191 | 192 | userData: { 193 | watched: false, 194 | lastWatched: '', 195 | personalRating: 0, 196 | }, 197 | }); 198 | } else if (type === 'game') { 199 | return new GameModel({ 200 | type: type, 201 | title: result.Title, 202 | englishTitle: result.Title, 203 | year: result.Year, 204 | dataSource: this.apiName, 205 | url: `https://www.imdb.com/title/${result.imdbID}/`, 206 | id: result.imdbID, 207 | 208 | developers: [], 209 | publishers: [], 210 | genres: result.Genre?.split(', ') ?? [], 211 | onlineRating: Number.parseFloat(result.imdbRating ?? 0), 212 | image: result.Poster ? result.Poster.replace('_SX300', '_SX600') : '', 213 | 214 | released: true, 215 | releaseDate: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat) ?? 'unknown', 216 | 217 | userData: { 218 | played: false, 219 | personalRating: 0, 220 | }, 221 | }); 222 | } 223 | 224 | throw new Error(`MDB | Unknown media type for id ${id}`); 225 | } 226 | 227 | getDisabledMediaTypes(): MediaType[] { 228 | return this.plugin.settings.OMDbAPI_disabledMediaTypes as MediaType[]; 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/api/apis/OpenLibraryAPI.ts: -------------------------------------------------------------------------------- 1 | import { BookModel } from 'src/models/BookModel'; 2 | import type MediaDbPlugin from '../../main'; 3 | import type { MediaTypeModel } from '../../models/MediaTypeModel'; 4 | import { MediaType } from '../../utils/MediaType'; 5 | import { APIModel } from '../APIModel'; 6 | 7 | export class OpenLibraryAPI extends APIModel { 8 | plugin: MediaDbPlugin; 9 | 10 | constructor(plugin: MediaDbPlugin) { 11 | super(); 12 | 13 | this.plugin = plugin; 14 | this.apiName = 'OpenLibraryAPI'; 15 | this.apiDescription = 'A free API for books'; 16 | this.apiUrl = 'https://openlibrary.org/'; 17 | this.types = [MediaType.Book]; 18 | } 19 | 20 | async searchByTitle(title: string): Promise { 21 | console.log(`MDB | api "${this.apiName}" queried by Title`); 22 | 23 | const searchUrl = `https://openlibrary.org/search.json?title=${encodeURIComponent(title)}`; 24 | 25 | const fetchData = await fetch(searchUrl); 26 | // console.debug(fetchData); 27 | if (fetchData.status !== 200) { 28 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 29 | } 30 | const data = await fetchData.json(); 31 | 32 | // console.debug(data); 33 | 34 | const ret: MediaTypeModel[] = []; 35 | 36 | for (const result of data.docs) { 37 | ret.push( 38 | new BookModel({ 39 | title: result.title, 40 | englishTitle: result.title_english ?? result.title, 41 | year: result.first_publish_year, 42 | dataSource: this.apiName, 43 | id: result.key, 44 | author: result.author_name ?? 'unknown', 45 | }), 46 | ); 47 | } 48 | 49 | return ret; 50 | } 51 | 52 | async getById(id: string): Promise { 53 | console.log(`MDB | api "${this.apiName}" queried by ID`); 54 | 55 | const searchUrl = `https://openlibrary.org/search.json?q=key:${encodeURIComponent(id)}`; 56 | const fetchData = await fetch(searchUrl); 57 | // console.debug(fetchData); 58 | 59 | if (fetchData.status !== 200) { 60 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 61 | } 62 | 63 | const data = await fetchData.json(); 64 | // console.debug(data); 65 | const result = data.docs[0]; 66 | 67 | return new BookModel({ 68 | title: result.title, 69 | year: result.first_publish_year, 70 | dataSource: this.apiName, 71 | url: `https://openlibrary.org` + result.key, 72 | id: result.key, 73 | isbn: (result.isbn ?? []).find((el: string | any[]) => el.length <= 10) ?? 'unknown', 74 | isbn13: (result.isbn ?? []).find((el: string | any[]) => el.length == 13) ?? 'unknown', 75 | englishTitle: result.title_english ?? result.title, 76 | 77 | author: result.author_name ?? 'unknown', 78 | plot: result.description ?? 'unknown', 79 | pages: result.number_of_pages_median ?? 'unknown', 80 | onlineRating: Number.parseFloat(Number(result.ratings_average ?? 0).toFixed(2)), 81 | image: `https://covers.openlibrary.org/b/OLID/` + result.cover_edition_key + `-L.jpg`, 82 | 83 | released: true, 84 | 85 | userData: { 86 | read: false, 87 | lastRead: '', 88 | personalRating: 0, 89 | }, 90 | }); 91 | } 92 | getDisabledMediaTypes(): MediaType[] { 93 | return this.plugin.settings.OpenLibraryAPI_disabledMediaTypes as MediaType[]; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/api/apis/SteamAPI.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from 'obsidian'; 2 | import type MediaDbPlugin from '../../main'; 3 | import { GameModel } from '../../models/GameModel'; 4 | import type { MediaTypeModel } from '../../models/MediaTypeModel'; 5 | import { MediaType } from '../../utils/MediaType'; 6 | import { APIModel } from '../APIModel'; 7 | import { imageUrlExists } from '../../utils/Utils'; 8 | 9 | export class SteamAPI extends APIModel { 10 | plugin: MediaDbPlugin; 11 | typeMappings: Map; 12 | apiDateFormat: string = 'DD MMM, YYYY'; 13 | 14 | constructor(plugin: MediaDbPlugin) { 15 | super(); 16 | 17 | this.plugin = plugin; 18 | this.apiName = 'SteamAPI'; 19 | this.apiDescription = 'A free API for all Steam games.'; 20 | this.apiUrl = 'https://www.steampowered.com/'; 21 | this.types = [MediaType.Game]; 22 | this.typeMappings = new Map(); 23 | this.typeMappings.set('game', 'game'); 24 | } 25 | 26 | async searchByTitle(title: string): Promise { 27 | console.log(`MDB | api "${this.apiName}" queried by Title`); 28 | 29 | const searchUrl = `https://steamcommunity.com/actions/SearchApps/${encodeURIComponent(title)}`; 30 | const fetchData = await requestUrl({ 31 | url: searchUrl, 32 | }); 33 | 34 | if (fetchData.status !== 200) { 35 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 36 | } 37 | 38 | const data = await fetchData.json; 39 | 40 | // console.debug(data); 41 | 42 | const ret: MediaTypeModel[] = []; 43 | 44 | for (const result of data) { 45 | ret.push( 46 | new GameModel({ 47 | type: MediaType.Game, 48 | title: result.name, 49 | englishTitle: result.name, 50 | year: '', 51 | dataSource: this.apiName, 52 | id: result.appid, 53 | }), 54 | ); 55 | } 56 | 57 | return ret; 58 | } 59 | 60 | async getById(id: string): Promise { 61 | console.log(`MDB | api "${this.apiName}" queried by ID`); 62 | 63 | const searchUrl = `https://store.steampowered.com/api/appdetails?appids=${encodeURIComponent(id)}&l=en`; 64 | const fetchData = await requestUrl({ 65 | url: searchUrl, 66 | }); 67 | 68 | if (fetchData.status !== 200) { 69 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 70 | } 71 | 72 | // console.debug(await fetchData.json); 73 | 74 | let result: any; 75 | for (const [key, value] of Object.entries(await fetchData.json)) { 76 | // console.log(typeof key, key) 77 | // console.log(typeof id, id) 78 | // after some testing I found out that id is somehow a number despite that it's defined as string... 79 | if (key === String(id)) { 80 | result = (value as any).data; 81 | } 82 | } 83 | if (!result) { 84 | throw Error(`MDB | API returned invalid data.`); 85 | } 86 | 87 | // console.debug(result); 88 | 89 | // Check if a poster version of the image exists, else use the header image 90 | const imageUrl = `https://steamcdn-a.akamaihd.net/steam/apps/${result.steam_appid}/library_600x900_2x.jpg`; 91 | const exists = await imageUrlExists(imageUrl); 92 | let finalimageurl; 93 | if (exists) { 94 | finalimageurl = imageUrl; 95 | } else { 96 | finalimageurl = result.header_image ?? ''; 97 | } 98 | 99 | return new GameModel({ 100 | type: MediaType.Game, 101 | title: result.name, 102 | englishTitle: result.name, 103 | year: new Date(result.release_date.date).getFullYear().toString(), 104 | dataSource: this.apiName, 105 | url: `https://store.steampowered.com/app/${result.steam_appid}`, 106 | id: result.steam_appid, 107 | 108 | developers: result.developers, 109 | publishers: result.publishers, 110 | genres: result.genres?.map((x: any) => x.description) ?? [], 111 | onlineRating: Number.parseFloat(result.metacritic?.score ?? 0), 112 | image: finalimageurl ?? '', 113 | 114 | released: !result.release_date?.coming_soon, 115 | releaseDate: this.plugin.dateFormatter.format(result.release_date?.date, this.apiDateFormat) ?? 'unknown', 116 | 117 | userData: { 118 | played: false, 119 | personalRating: 0, 120 | }, 121 | }); 122 | } 123 | getDisabledMediaTypes(): MediaType[] { 124 | return this.plugin.settings.SteamAPI_disabledMediaTypes as MediaType[]; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/api/apis/WikipediaAPI.ts: -------------------------------------------------------------------------------- 1 | import type MediaDbPlugin from '../../main'; 2 | import type { MediaTypeModel } from '../../models/MediaTypeModel'; 3 | import { WikiModel } from '../../models/WikiModel'; 4 | import { MediaType } from '../../utils/MediaType'; 5 | import { APIModel } from '../APIModel'; 6 | 7 | export class WikipediaAPI extends APIModel { 8 | plugin: MediaDbPlugin; 9 | apiDateFormat: string = 'YYYY-MM-DDTHH:mm:ssZ'; // ISO 10 | 11 | constructor(plugin: MediaDbPlugin) { 12 | super(); 13 | 14 | this.plugin = plugin; 15 | this.apiName = 'Wikipedia API'; 16 | this.apiDescription = 'The API behind Wikipedia'; 17 | this.apiUrl = 'https://www.wikipedia.com'; 18 | this.types = [MediaType.Wiki]; 19 | } 20 | 21 | async searchByTitle(title: string): Promise { 22 | console.log(`MDB | api "${this.apiName}" queried by Title`); 23 | 24 | const searchUrl = `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(title)}&srlimit=20&utf8=&format=json&origin=*`; 25 | const fetchData = await fetch(searchUrl); 26 | // console.debug(fetchData); 27 | 28 | if (fetchData.status !== 200) { 29 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 30 | } 31 | 32 | const data = await fetchData.json(); 33 | console.debug(data); 34 | const ret: MediaTypeModel[] = []; 35 | 36 | for (const result of data.query.search) { 37 | ret.push( 38 | new WikiModel({ 39 | type: 'wiki', 40 | title: result.title, 41 | englishTitle: result.title, 42 | year: '', 43 | dataSource: this.apiName, 44 | id: result.pageid, 45 | }), 46 | ); 47 | } 48 | 49 | return ret; 50 | } 51 | 52 | async getById(id: string): Promise { 53 | console.log(`MDB | api "${this.apiName}" queried by ID`); 54 | 55 | const searchUrl = `https://en.wikipedia.org/w/api.php?action=query&prop=info&pageids=${encodeURIComponent(id)}&inprop=url&format=json&origin=*`; 56 | const fetchData = await fetch(searchUrl); 57 | 58 | if (fetchData.status !== 200) { 59 | throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); 60 | } 61 | 62 | const data = await fetchData.json(); 63 | // console.debug(data); 64 | const result: any = Object.entries(data?.query?.pages)[0][1]; 65 | 66 | return new WikiModel({ 67 | type: 'wiki', 68 | title: result.title, 69 | englishTitle: result.title, 70 | year: '', 71 | dataSource: this.apiName, 72 | url: result.fullurl, 73 | id: result.pageid, 74 | 75 | wikiUrl: result.fullurl, 76 | lastUpdated: this.plugin.dateFormatter.format(result.touched, this.apiDateFormat) ?? undefined, 77 | length: result.length, 78 | 79 | userData: {}, 80 | }); 81 | } 82 | getDisabledMediaTypes(): MediaType[] { 83 | return this.plugin.settings.WikipediaAPI_disabledMediaTypes as MediaType[]; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/modals/ConfirmOverwriteModal.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'obsidian'; 2 | import { Modal, Setting } from 'obsidian'; 3 | 4 | export class ConfirmOverwriteModal extends Modal { 5 | result: boolean = false; 6 | onSubmit: (result: boolean) => void; 7 | fileName: string; 8 | 9 | constructor(app: App, fileName: string, onSubmit: (result: boolean) => void) { 10 | super(app); 11 | this.fileName = fileName; 12 | this.onSubmit = onSubmit; 13 | } 14 | 15 | onOpen() { 16 | const { contentEl } = this; 17 | contentEl.createEl('h2', { text: 'File already exists' }); 18 | contentEl.createEl('p', { text: `The file "${this.fileName}" already exists. Do you want to overwrite it?` }); 19 | 20 | contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); 21 | 22 | const bottomSettingRow = new Setting(contentEl); 23 | bottomSettingRow.addButton(btn => { 24 | btn.setButtonText('No'); 25 | btn.onClick(() => this.close()); 26 | btn.buttonEl.addClass('media-db-plugin-button'); 27 | }); 28 | bottomSettingRow.addButton(btn => { 29 | btn.setButtonText('Yes'); 30 | btn.setCta(); 31 | btn.onClick(() => { 32 | this.result = true; 33 | this.close(); 34 | }); 35 | btn.buttonEl.addClass('media-db-plugin-button'); 36 | }); 37 | } 38 | 39 | onClose() { 40 | const { contentEl } = this; 41 | contentEl.empty(); 42 | this.onSubmit(this.result); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/modals/MediaDbAdvancedSearchModal.ts: -------------------------------------------------------------------------------- 1 | import type { ButtonComponent } from 'obsidian'; 2 | import { Modal, Notice, Setting, TextComponent, ToggleComponent } from 'obsidian'; 3 | import type MediaDbPlugin from '../main'; 4 | import type { MediaTypeModel } from '../models/MediaTypeModel'; 5 | import type { AdvancedSearchModalData, AdvancedSearchModalOptions } from '../utils/ModalHelper'; 6 | import { ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS } from '../utils/ModalHelper'; 7 | 8 | export class MediaDbAdvancedSearchModal extends Modal { 9 | plugin: MediaDbPlugin; 10 | 11 | query: string; 12 | isBusy: boolean; 13 | title: string; 14 | selectedApis: string[]; 15 | 16 | searchBtn?: ButtonComponent; 17 | 18 | submitCallback?: (res: AdvancedSearchModalData) => void; 19 | closeCallback?: (err?: Error) => void; 20 | 21 | constructor(plugin: MediaDbPlugin, advancedSearchModalOptions: AdvancedSearchModalOptions) { 22 | advancedSearchModalOptions = Object.assign({}, ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS, advancedSearchModalOptions); 23 | super(plugin.app); 24 | 25 | this.plugin = plugin; 26 | this.selectedApis = []; 27 | this.title = advancedSearchModalOptions.modalTitle ?? ''; 28 | this.query = advancedSearchModalOptions.prefilledSearchString ?? ''; 29 | this.isBusy = false; 30 | } 31 | 32 | setSubmitCallback(submitCallback: (res: AdvancedSearchModalData) => void): void { 33 | this.submitCallback = submitCallback; 34 | } 35 | 36 | setCloseCallback(closeCallback: (err?: Error) => void): void { 37 | this.closeCallback = closeCallback; 38 | } 39 | 40 | keyPressCallback(event: KeyboardEvent): void { 41 | if (event.key === 'Enter') { 42 | this.search(); 43 | } 44 | } 45 | 46 | async search(): Promise { 47 | if (!this.query || this.query.length < 3) { 48 | new Notice('MDB | Query too short'); 49 | return; 50 | } 51 | 52 | const apis: string[] = this.selectedApis; 53 | 54 | if (apis.length === 0) { 55 | new Notice('MDB | No API selected'); 56 | return; 57 | } 58 | 59 | if (!this.isBusy) { 60 | this.isBusy = true; 61 | this.searchBtn?.setDisabled(false); 62 | this.searchBtn?.setButtonText('Searching...'); 63 | 64 | this.submitCallback?.({ query: this.query, apis: apis }); 65 | } 66 | } 67 | 68 | onOpen(): void { 69 | const { contentEl } = this; 70 | 71 | contentEl.createEl('h2', { text: this.title }); 72 | 73 | const placeholder = 'Search by title'; 74 | const searchComponent = new TextComponent(contentEl); 75 | searchComponent.inputEl.style.width = '100%'; 76 | searchComponent.setPlaceholder(placeholder); 77 | searchComponent.setValue(this.query); 78 | searchComponent.onChange(value => (this.query = value)); 79 | searchComponent.inputEl.addEventListener('keydown', this.keyPressCallback.bind(this)); 80 | 81 | contentEl.appendChild(searchComponent.inputEl); 82 | searchComponent.inputEl.focus(); 83 | 84 | contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); 85 | contentEl.createEl('h3', { text: 'APIs to search' }); 86 | 87 | // const apiToggleComponents: Component[] = []; 88 | for (const api of this.plugin.apiManager.apis) { 89 | const apiToggleListElementWrapper = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' }); 90 | 91 | const apiToggleTextWrapper = apiToggleListElementWrapper.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' }); 92 | apiToggleTextWrapper.createEl('span', { text: api.apiName, cls: 'media-db-plugin-list-text' }); 93 | apiToggleTextWrapper.createEl('small', { text: api.apiDescription, cls: 'media-db-plugin-list-text' }); 94 | 95 | const apiToggleComponentWrapper = apiToggleListElementWrapper.createEl('div', { cls: 'media-db-plugin-list-toggle' }); 96 | 97 | const apiToggleComponent = new ToggleComponent(apiToggleComponentWrapper); 98 | apiToggleComponent.setTooltip(api.apiName); 99 | apiToggleComponent.setValue(this.selectedApis.some(x => x === api.apiName)); 100 | apiToggleComponent.onChange(value => { 101 | if (value) { 102 | this.selectedApis.push(api.apiName); 103 | } else { 104 | this.selectedApis = this.selectedApis.filter(x => x !== api.apiName); 105 | } 106 | }); 107 | apiToggleComponentWrapper.appendChild(apiToggleComponent.toggleEl); 108 | } 109 | 110 | contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); 111 | 112 | new Setting(contentEl) 113 | .addButton(btn => { 114 | btn.setButtonText('Cancel'); 115 | btn.onClick(() => this.close()); 116 | btn.buttonEl.addClass('media-db-plugin-button'); 117 | }) 118 | .addButton(btn => { 119 | btn.setButtonText('Ok'); 120 | btn.setCta(); 121 | btn.onClick(() => { 122 | this.search(); 123 | }); 124 | btn.buttonEl.addClass('media-db-plugin-button'); 125 | this.searchBtn = btn; 126 | }); 127 | } 128 | 129 | onClose(): void { 130 | this.closeCallback?.(); 131 | const { contentEl } = this; 132 | contentEl.empty(); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/modals/MediaDbFolderImportModal.ts: -------------------------------------------------------------------------------- 1 | import type { App, ButtonComponent } from 'obsidian'; 2 | import { DropdownComponent, Modal, Setting, TextComponent, ToggleComponent } from 'obsidian'; 3 | import type MediaDbPlugin from '../main'; 4 | 5 | export class MediaDbFolderImportModal extends Modal { 6 | plugin: MediaDbPlugin; 7 | onSubmit: (selectedAPI: string, titleFieldName: string, appendContent: boolean) => void; 8 | selectedApi: string; 9 | searchBtn?: ButtonComponent; 10 | titleFieldName: string; 11 | appendContent: boolean; 12 | 13 | constructor(app: App, plugin: MediaDbPlugin, onSubmit: (selectedAPI: string, titleFieldName: string, appendContent: boolean) => void) { 14 | super(app); 15 | this.plugin = plugin; 16 | this.onSubmit = onSubmit; 17 | this.selectedApi = plugin.apiManager.apis[0].apiName; 18 | this.titleFieldName = ''; 19 | this.appendContent = false; 20 | } 21 | 22 | submit(): void { 23 | this.onSubmit(this.selectedApi, this.titleFieldName, this.appendContent); 24 | this.close(); 25 | } 26 | 27 | onOpen(): void { 28 | const { contentEl } = this; 29 | 30 | contentEl.createEl('h2', { text: 'Import folder as Media DB entries' }); 31 | 32 | const apiSelectorWrapper = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' }); 33 | const apiSelectorTextWrapper = apiSelectorWrapper.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' }); 34 | apiSelectorTextWrapper.createEl('span', { text: 'API to search', cls: 'media-db-plugin-list-text' }); 35 | 36 | const apiSelectorComponent = new DropdownComponent(apiSelectorWrapper); 37 | apiSelectorComponent.onChange((value: string) => { 38 | this.selectedApi = value; 39 | }); 40 | for (const api of this.plugin.apiManager.apis) { 41 | apiSelectorComponent.addOption(api.apiName, api.apiName); 42 | } 43 | apiSelectorWrapper.appendChild(apiSelectorComponent.selectEl); 44 | 45 | contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); 46 | contentEl.createEl('h3', { text: 'Append note content to Media DB entry.' }); 47 | 48 | const appendContentToggleElementWrapper = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' }); 49 | const appendContentToggleTextWrapper = appendContentToggleElementWrapper.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' }); 50 | appendContentToggleTextWrapper.createEl('span', { 51 | text: 'If this is enabled, the plugin will override metadata fields with the same name.', 52 | cls: 'media-db-plugin-list-text', 53 | }); 54 | 55 | const appendContentToggleComponentWrapper = appendContentToggleElementWrapper.createEl('div', { cls: 'media-db-plugin-list-toggle' }); 56 | 57 | const appendContentToggle = new ToggleComponent(appendContentToggleElementWrapper); 58 | appendContentToggle.setValue(false); 59 | appendContentToggle.onChange(value => (this.appendContent = value)); 60 | appendContentToggleComponentWrapper.appendChild(appendContentToggle.toggleEl); 61 | 62 | contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); 63 | contentEl.createEl('h3', { text: 'The name of the metadata field that should be used as the title to query.' }); 64 | 65 | const placeholder = 'title'; 66 | const titleFieldNameComponent = new TextComponent(contentEl); 67 | titleFieldNameComponent.inputEl.style.width = '100%'; 68 | titleFieldNameComponent.setPlaceholder(placeholder); 69 | titleFieldNameComponent.onChange(value => (this.titleFieldName = value)); 70 | titleFieldNameComponent.inputEl.addEventListener('keydown', ke => { 71 | if (ke.key === 'Enter') { 72 | this.submit(); 73 | } 74 | }); 75 | contentEl.appendChild(titleFieldNameComponent.inputEl); 76 | 77 | contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); 78 | 79 | new Setting(contentEl) 80 | .addButton(btn => { 81 | btn.setButtonText('Cancel'); 82 | btn.onClick(() => this.close()); 83 | btn.buttonEl.addClass('media-db-plugin-button'); 84 | }) 85 | .addButton(btn => { 86 | btn.setButtonText('Ok'); 87 | btn.setCta(); 88 | btn.onClick(() => { 89 | this.submit(); 90 | }); 91 | btn.buttonEl.addClass('media-db-plugin-button'); 92 | this.searchBtn = btn; 93 | }); 94 | } 95 | 96 | onClose(): void { 97 | const { contentEl } = this; 98 | contentEl.empty(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/modals/MediaDbIdSearchModal.ts: -------------------------------------------------------------------------------- 1 | import type { ButtonComponent } from 'obsidian'; 2 | import { DropdownComponent, Modal, Notice, Setting, TextComponent } from 'obsidian'; 3 | import type MediaDbPlugin from '../main'; 4 | import type { MediaTypeModel } from '../models/MediaTypeModel'; 5 | import type { IdSearchModalData, IdSearchModalOptions } from '../utils/ModalHelper'; 6 | import { ID_SEARCH_MODAL_DEFAULT_OPTIONS } from '../utils/ModalHelper'; 7 | 8 | export class MediaDbIdSearchModal extends Modal { 9 | plugin: MediaDbPlugin; 10 | 11 | query: string; 12 | isBusy: boolean; 13 | title: string; 14 | selectedApi: string; 15 | 16 | searchBtn?: ButtonComponent; 17 | 18 | submitCallback?: (res: IdSearchModalData, err?: Error) => void; 19 | closeCallback?: (err?: Error) => void; 20 | 21 | constructor(plugin: MediaDbPlugin, idSearchModalOptions: IdSearchModalOptions) { 22 | idSearchModalOptions = Object.assign({}, ID_SEARCH_MODAL_DEFAULT_OPTIONS, idSearchModalOptions); 23 | super(plugin.app); 24 | 25 | this.plugin = plugin; 26 | this.title = idSearchModalOptions.modalTitle ?? ''; 27 | this.selectedApi = idSearchModalOptions.preselectedAPI || plugin.apiManager.apis[0].apiName; 28 | this.query = ''; 29 | this.isBusy = false; 30 | } 31 | 32 | setSubmitCallback(submitCallback: (res: IdSearchModalData, err?: Error) => void): void { 33 | this.submitCallback = submitCallback; 34 | } 35 | 36 | setCloseCallback(closeCallback: (err?: Error) => void): void { 37 | this.closeCallback = closeCallback; 38 | } 39 | 40 | keyPressCallback(event: KeyboardEvent): void { 41 | if (event.key === 'Enter') { 42 | this.search(); 43 | } 44 | } 45 | 46 | async search(): Promise { 47 | if (!this.query) { 48 | new Notice('MDB | no Id entered'); 49 | return; 50 | } 51 | 52 | if (!this.selectedApi) { 53 | new Notice('MDB | No API selected'); 54 | return; 55 | } 56 | 57 | if (!this.isBusy) { 58 | this.isBusy = true; 59 | this.searchBtn?.setDisabled(false); 60 | this.searchBtn?.setButtonText('Searching...'); 61 | 62 | this.submitCallback?.({ query: this.query, api: this.selectedApi }); 63 | } 64 | } 65 | 66 | onOpen(): void { 67 | const { contentEl } = this; 68 | 69 | contentEl.createEl('h2', { text: this.title }); 70 | 71 | const placeholder = 'Search by id'; 72 | const searchComponent = new TextComponent(contentEl); 73 | searchComponent.inputEl.style.width = '100%'; 74 | searchComponent.setPlaceholder(placeholder); 75 | searchComponent.onChange(value => (this.query = value)); 76 | searchComponent.inputEl.addEventListener('keydown', this.keyPressCallback.bind(this)); 77 | 78 | contentEl.appendChild(searchComponent.inputEl); 79 | searchComponent.inputEl.focus(); 80 | 81 | contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); 82 | 83 | const apiSelectorWrapper = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' }); 84 | const apiSelectorTExtWrapper = apiSelectorWrapper.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' }); 85 | apiSelectorTExtWrapper.createEl('span', { text: 'API to search', cls: 'media-db-plugin-list-text' }); 86 | 87 | const apiSelectorComponent = new DropdownComponent(apiSelectorWrapper); 88 | apiSelectorComponent.onChange((value: string) => { 89 | this.selectedApi = value; 90 | }); 91 | for (const api of this.plugin.apiManager.apis) { 92 | apiSelectorComponent.addOption(api.apiName, api.apiName); 93 | } 94 | apiSelectorWrapper.appendChild(apiSelectorComponent.selectEl); 95 | 96 | contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); 97 | 98 | new Setting(contentEl) 99 | .addButton(btn => { 100 | btn.setButtonText('Cancel'); 101 | btn.onClick(() => this.close()); 102 | btn.buttonEl.addClass('media-db-plugin-button'); 103 | }) 104 | .addButton(btn => { 105 | btn.setButtonText('Ok'); 106 | btn.setCta(); 107 | btn.onClick(() => { 108 | this.search(); 109 | }); 110 | btn.buttonEl.addClass('media-db-plugin-button'); 111 | this.searchBtn = btn; 112 | }); 113 | } 114 | 115 | onClose(): void { 116 | this.closeCallback?.(); 117 | const { contentEl } = this; 118 | contentEl.empty(); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/modals/MediaDbPreviewModal.ts: -------------------------------------------------------------------------------- 1 | import type { ButtonComponent } from 'obsidian'; 2 | import { Component, MarkdownRenderer, Modal, Setting } from 'obsidian'; 3 | import type MediaDbPlugin from 'src/main'; 4 | import type { MediaTypeModel } from 'src/models/MediaTypeModel'; 5 | import type { PreviewModalData, PreviewModalOptions } from '../utils/ModalHelper'; 6 | import { PREVIEW_MODAL_DEFAULT_OPTIONS } from '../utils/ModalHelper'; 7 | 8 | export class MediaDbPreviewModal extends Modal { 9 | plugin: MediaDbPlugin; 10 | 11 | elements: MediaTypeModel[]; 12 | title: string; 13 | markdownComponent: Component; 14 | 15 | submitCallback?: (previewModalData: PreviewModalData) => void; 16 | closeCallback?: (err?: Error) => void; 17 | 18 | constructor(plugin: MediaDbPlugin, previewModalOptions: PreviewModalOptions) { 19 | previewModalOptions = Object.assign({}, PREVIEW_MODAL_DEFAULT_OPTIONS, previewModalOptions); 20 | 21 | super(plugin.app); 22 | 23 | this.plugin = plugin; 24 | this.title = previewModalOptions.modalTitle ?? ''; 25 | this.elements = previewModalOptions.elements ?? []; 26 | 27 | this.markdownComponent = new Component(); 28 | } 29 | 30 | setSubmitCallback(submitCallback: (previewModalData: PreviewModalData) => void): void { 31 | this.submitCallback = submitCallback; 32 | } 33 | 34 | setCloseCallback(closeCallback: (err?: Error) => void): void { 35 | this.closeCallback = closeCallback; 36 | } 37 | 38 | async preview(): Promise { 39 | const { contentEl } = this; 40 | contentEl.addClass('media-db-plugin-preview-modal'); 41 | 42 | contentEl.createEl('h2', { text: this.title }); 43 | 44 | const previewWrapper = contentEl.createDiv({ cls: 'media-db-plugin-preview-wrapper' }); 45 | 46 | this.markdownComponent.load(); 47 | 48 | for (const result of this.elements) { 49 | previewWrapper.createEl('h3', { text: result.englishTitle }); 50 | const fileDiv = previewWrapper.createDiv({ cls: 'media-db-plugin-preview' }); 51 | 52 | let fileContent = this.plugin.generateMediaDbNoteFrontmatterPreview(result); 53 | fileContent = `\`\`\`yaml\n${fileContent}\`\`\``; 54 | 55 | try { 56 | // TODO: fix this not rendering the frontmatter any more 57 | await MarkdownRenderer.render(this.app, fileContent, fileDiv, '', this.markdownComponent); 58 | } catch (e) { 59 | console.warn(`mdb | error during rendering of preview`, e); 60 | } 61 | } 62 | 63 | contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); 64 | 65 | const bottomSettingRow = new Setting(contentEl); 66 | bottomSettingRow.addButton(btn => { 67 | btn.setButtonText('Cancel'); 68 | btn.onClick(() => this.close()); 69 | btn.buttonEl.addClass('media-db-plugin-button'); 70 | }); 71 | bottomSettingRow.addButton(btn => { 72 | btn.setButtonText('Ok'); 73 | btn.setCta(); 74 | btn.onClick(() => this.submitCallback?.({ confirmed: true })); 75 | btn.buttonEl.addClass('media-db-plugin-button'); 76 | }); 77 | } 78 | 79 | onOpen(): void { 80 | void this.preview(); 81 | } 82 | 83 | onClose(): void { 84 | this.markdownComponent.unload(); 85 | this.closeCallback?.(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/modals/MediaDbSearchModal.ts: -------------------------------------------------------------------------------- 1 | import type { ButtonComponent } from 'obsidian'; 2 | import { Modal, Notice, Setting, TextComponent, ToggleComponent } from 'obsidian'; 3 | import type MediaDbPlugin from '../main'; 4 | import type { MediaTypeModel } from '../models/MediaTypeModel'; 5 | import type { MediaType } from '../utils/MediaType'; 6 | import { MEDIA_TYPES } from '../utils/MediaTypeManager'; 7 | import type { SearchModalData, SearchModalOptions } from '../utils/ModalHelper'; 8 | import { SEARCH_MODAL_DEFAULT_OPTIONS } from '../utils/ModalHelper'; 9 | import { unCamelCase } from '../utils/Utils'; 10 | 11 | export class MediaDbSearchModal extends Modal { 12 | plugin: MediaDbPlugin; 13 | 14 | query: string; 15 | isBusy: boolean; 16 | title: string; 17 | selectedTypes: MediaType[]; 18 | 19 | searchBtn?: ButtonComponent; 20 | 21 | submitCallback?: (res: SearchModalData) => void; 22 | closeCallback?: (err?: Error) => void; 23 | 24 | constructor(plugin: MediaDbPlugin, searchModalOptions: SearchModalOptions) { 25 | searchModalOptions = Object.assign({}, SEARCH_MODAL_DEFAULT_OPTIONS, searchModalOptions); 26 | super(plugin.app); 27 | 28 | this.plugin = plugin; 29 | this.selectedTypes = [...(searchModalOptions.preselectedTypes ?? [])]; 30 | this.title = searchModalOptions.modalTitle ?? ''; 31 | this.query = searchModalOptions.prefilledSearchString ?? ''; 32 | this.isBusy = false; 33 | } 34 | 35 | setSubmitCallback(submitCallback: (res: SearchModalData) => void): void { 36 | this.submitCallback = submitCallback; 37 | } 38 | 39 | setCloseCallback(closeCallback: (err?: Error) => void): void { 40 | this.closeCallback = closeCallback; 41 | } 42 | 43 | keyPressCallback(event: KeyboardEvent): void { 44 | if (event.key === 'Enter') { 45 | this.search(); 46 | } 47 | } 48 | 49 | async search(): Promise { 50 | if (!this.query || this.query.length < 3) { 51 | new Notice('MDB | Query too short'); 52 | return; 53 | } 54 | 55 | const types: MediaType[] = this.selectedTypes; 56 | 57 | if (types.length === 0) { 58 | new Notice('MDB | No Type selected'); 59 | return; 60 | } 61 | 62 | if (!this.isBusy) { 63 | this.isBusy = true; 64 | this.searchBtn?.setDisabled(false); 65 | this.searchBtn?.setButtonText('Searching...'); 66 | 67 | this.submitCallback?.({ query: this.query, types: types }); 68 | } 69 | } 70 | 71 | onOpen(): void { 72 | const { contentEl } = this; 73 | 74 | contentEl.createEl('h2', { text: this.title }); 75 | 76 | const placeholder = 'Search by title'; 77 | const searchComponent = new TextComponent(contentEl); 78 | let currentToggle: ToggleComponent | undefined = undefined; 79 | 80 | searchComponent.inputEl.style.width = '100%'; 81 | searchComponent.setPlaceholder(placeholder); 82 | searchComponent.setValue(this.query); 83 | searchComponent.onChange(value => (this.query = value)); 84 | searchComponent.inputEl.addEventListener('keydown', this.keyPressCallback.bind(this)); 85 | 86 | contentEl.appendChild(searchComponent.inputEl); 87 | searchComponent.inputEl.focus(); 88 | 89 | contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); 90 | contentEl.createEl('h3', { text: 'APIs to search' }); 91 | 92 | for (const mediaType of MEDIA_TYPES) { 93 | const apiToggleListElementWrapper = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' }); 94 | 95 | const apiToggleTextWrapper = apiToggleListElementWrapper.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' }); 96 | apiToggleTextWrapper.createEl('span', { text: unCamelCase(mediaType), cls: 'media-db-plugin-list-text' }); 97 | 98 | const apiToggleComponentWrapper = apiToggleListElementWrapper.createEl('div', { cls: 'media-db-plugin-list-toggle' }); 99 | 100 | const apiToggleComponent = new ToggleComponent(apiToggleComponentWrapper); 101 | apiToggleComponent.setTooltip(unCamelCase(mediaType)); 102 | apiToggleComponent.setValue(this.selectedTypes.contains(mediaType)); 103 | if (apiToggleComponent.getValue()) { 104 | currentToggle = apiToggleComponent; 105 | } 106 | apiToggleComponent.onChange(value => { 107 | if (value) { 108 | if (currentToggle && currentToggle !== apiToggleComponent) { 109 | currentToggle.setValue(false); 110 | this.selectedTypes = this.selectedTypes.filter(x => x !== mediaType); 111 | } 112 | currentToggle = apiToggleComponent; 113 | this.selectedTypes.push(mediaType); 114 | } else { 115 | currentToggle = undefined; 116 | this.selectedTypes = this.selectedTypes.filter(x => x !== mediaType); 117 | } 118 | }); 119 | apiToggleComponentWrapper.appendChild(apiToggleComponent.toggleEl); 120 | } 121 | 122 | contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); 123 | 124 | new Setting(contentEl) 125 | .addButton(btn => { 126 | btn.setButtonText('Cancel'); 127 | btn.onClick(() => this.close()); 128 | btn.buttonEl.addClass('media-db-plugin-button'); 129 | }) 130 | .addButton(btn => { 131 | btn.setButtonText('Ok'); 132 | btn.setCta(); 133 | btn.onClick(() => { 134 | this.search(); 135 | }); 136 | btn.buttonEl.addClass('media-db-plugin-button'); 137 | this.searchBtn = btn; 138 | }); 139 | } 140 | 141 | onClose(): void { 142 | this.closeCallback?.(); 143 | const { contentEl } = this; 144 | contentEl.empty(); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/modals/MediaDbSearchResultModal.ts: -------------------------------------------------------------------------------- 1 | import type MediaDbPlugin from '../main'; 2 | import type { MediaTypeModel } from '../models/MediaTypeModel'; 3 | import type { SelectModalData, SelectModalOptions } from '../utils/ModalHelper'; 4 | import { SELECT_MODAL_OPTIONS_DEFAULT } from '../utils/ModalHelper'; 5 | import { SelectModal } from './SelectModal'; 6 | 7 | export class MediaDbSearchResultModal extends SelectModal { 8 | plugin: MediaDbPlugin; 9 | 10 | busy: boolean; 11 | sendCallback: boolean; 12 | 13 | submitCallback?: (res: SelectModalData) => void; 14 | closeCallback?: (err?: Error) => void; 15 | skipCallback?: () => void; 16 | 17 | constructor(plugin: MediaDbPlugin, selectModalOptions: SelectModalOptions) { 18 | selectModalOptions = Object.assign({}, SELECT_MODAL_OPTIONS_DEFAULT, selectModalOptions); 19 | super(plugin.app, selectModalOptions.elements ?? [], selectModalOptions.multiSelect); 20 | this.plugin = plugin; 21 | 22 | this.title = selectModalOptions.modalTitle ?? ''; 23 | this.description = 'Select one or multiple search results.'; 24 | this.addSkipButton = selectModalOptions.skipButton ?? false; 25 | 26 | this.busy = false; 27 | 28 | this.sendCallback = false; 29 | } 30 | 31 | setSubmitCallback(submitCallback: (res: SelectModalData) => void): void { 32 | this.submitCallback = submitCallback; 33 | } 34 | 35 | setCloseCallback(closeCallback: (err?: Error) => void): void { 36 | this.closeCallback = closeCallback; 37 | } 38 | 39 | setSkipCallback(skipCallback: () => void): void { 40 | this.skipCallback = skipCallback; 41 | } 42 | 43 | // Renders each suggestion item. 44 | renderElement(item: MediaTypeModel, el: HTMLElement): void { 45 | el.createEl('div', { text: this.plugin.mediaTypeManager.getFileName(item) }); 46 | el.createEl('small', { text: `${item.getSummary()}\n` }); 47 | el.createEl('small', { text: `${item.type.toUpperCase() + (item.subType ? ` (${item.subType})` : '')} from ${item.dataSource}` }); 48 | } 49 | 50 | // Perform action on the selected suggestion. 51 | submit(): void { 52 | if (!this.busy) { 53 | this.busy = true; 54 | this.submitButton?.setButtonText('Creating entry...'); 55 | this.submitCallback?.({ selected: this.selectModalElements.filter(x => x.isActive()).map(x => x.value) }); 56 | } 57 | } 58 | 59 | skip(): void { 60 | this.skipButton?.setButtonText('Skipping...'); 61 | this.skipCallback?.(); 62 | } 63 | 64 | onClose(): void { 65 | this.closeCallback?.(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/modals/SelectModal.ts: -------------------------------------------------------------------------------- 1 | import type { App, ButtonComponent } from 'obsidian'; 2 | import { Modal, Setting } from 'obsidian'; 3 | import { mod } from '../utils/Utils'; 4 | import { SelectModalElement } from './SelectModalElement'; 5 | 6 | export abstract class SelectModal extends Modal { 7 | allowMultiSelect: boolean; 8 | 9 | title: string; 10 | description: string; 11 | addSkipButton: boolean; 12 | cancelButton?: ButtonComponent; 13 | skipButton?: ButtonComponent; 14 | submitButton?: ButtonComponent; 15 | 16 | elementWrapper?: HTMLDivElement; 17 | 18 | elements: T[]; 19 | selectModalElements: SelectModalElement[]; 20 | 21 | protected constructor(app: App, elements: T[], allowMultiSelect: boolean = true) { 22 | super(app); 23 | this.allowMultiSelect = allowMultiSelect; 24 | 25 | this.title = ''; 26 | this.description = ''; 27 | this.addSkipButton = false; 28 | this.cancelButton = undefined; 29 | this.skipButton = undefined; 30 | this.submitButton = undefined; 31 | 32 | this.elementWrapper = undefined; 33 | 34 | this.elements = elements; 35 | this.selectModalElements = []; 36 | 37 | this.scope.register([], 'ArrowUp', evt => { 38 | this.highlightUp(); 39 | evt.preventDefault(); 40 | }); 41 | this.scope.register([], 'ArrowDown', evt => { 42 | this.highlightDown(); 43 | evt.preventDefault(); 44 | }); 45 | this.scope.register([], 'ArrowRight', () => { 46 | this.activateHighlighted(); 47 | }); 48 | this.scope.register([], ' ', evt => { 49 | if (this.elementWrapper && this.elementWrapper === document.activeElement) { 50 | this.activateHighlighted(); 51 | evt.preventDefault(); 52 | } 53 | }); 54 | this.scope.register([], 'Enter', () => this.submit()); 55 | } 56 | 57 | abstract renderElement(value: T, el: HTMLElement): any; 58 | 59 | abstract submit(): void; 60 | 61 | abstract skip(): void; 62 | 63 | disableAllOtherElements(elementId: number): void { 64 | for (const selectModalElement of this.selectModalElements) { 65 | if (selectModalElement.id !== elementId) { 66 | selectModalElement.setActive(false); 67 | } 68 | } 69 | } 70 | 71 | deHighlightAllOtherElements(elementId: number): void { 72 | for (const selectModalElement of this.selectModalElements) { 73 | if (selectModalElement.id !== elementId) { 74 | selectModalElement.setHighlighted(false); 75 | } 76 | } 77 | } 78 | 79 | async onOpen(): Promise { 80 | const { contentEl, titleEl } = this; 81 | 82 | titleEl.createEl('h2', { text: this.title }); 83 | contentEl.addClass('media-db-plugin-select-modal'); 84 | contentEl.createEl('p', { text: this.description }); 85 | 86 | this.elementWrapper = contentEl.createDiv({ cls: 'media-db-plugin-select-wrapper' }); 87 | this.elementWrapper.tabIndex = 0; 88 | 89 | let i = 0; 90 | for (const element of this.elements) { 91 | const selectModalElement = new SelectModalElement(element, this.elementWrapper, i, this, false); 92 | 93 | this.selectModalElements.push(selectModalElement); 94 | 95 | this.renderElement(element, selectModalElement.element); 96 | 97 | i += 1; 98 | } 99 | 100 | this.selectModalElements.first()?.element.scrollIntoView(); 101 | 102 | const bottomSettingRow = new Setting(contentEl); 103 | bottomSettingRow.addButton(btn => { 104 | btn.setButtonText('Cancel'); 105 | btn.onClick(() => this.close()); 106 | btn.buttonEl.addClass('media-db-plugin-button'); 107 | this.cancelButton = btn; 108 | }); 109 | if (this.addSkipButton) { 110 | bottomSettingRow.addButton(btn => { 111 | btn.setButtonText('Skip'); 112 | btn.onClick(() => this.skip()); 113 | btn.buttonEl.addClass('media-db-plugin-button'); 114 | this.skipButton = btn; 115 | }); 116 | } 117 | bottomSettingRow.addButton(btn => { 118 | btn.setButtonText('Ok'); 119 | btn.setCta(); 120 | btn.onClick(() => this.submit()); 121 | btn.buttonEl.addClass('media-db-plugin-button'); 122 | this.submitButton = btn; 123 | }); 124 | } 125 | 126 | activateHighlighted(): void { 127 | for (const selectModalElement of this.selectModalElements) { 128 | if (selectModalElement.isHighlighted()) { 129 | selectModalElement.setActive(!selectModalElement.isActive()); 130 | if (!this.allowMultiSelect) { 131 | this.disableAllOtherElements(selectModalElement.id); 132 | } 133 | } 134 | } 135 | } 136 | 137 | highlightUp(): void { 138 | for (const selectModalElement of this.selectModalElements) { 139 | if (selectModalElement.isHighlighted()) { 140 | this.getPreviousSelectModalElement(selectModalElement).setHighlighted(true); 141 | return; 142 | } 143 | } 144 | 145 | // nothing is highlighted 146 | this.selectModalElements.last()?.setHighlighted(true); 147 | } 148 | 149 | highlightDown(): void { 150 | for (const selectModalElement of this.selectModalElements) { 151 | if (selectModalElement.isHighlighted()) { 152 | this.getNextSelectModalElement(selectModalElement).setHighlighted(true); 153 | return; 154 | } 155 | } 156 | 157 | // nothing is highlighted 158 | this.selectModalElements.first()?.setHighlighted(true); 159 | } 160 | 161 | private getNextSelectModalElement(selectModalElement: SelectModalElement): SelectModalElement { 162 | let nextId = selectModalElement.id + 1; 163 | nextId = mod(nextId, this.selectModalElements.length); 164 | 165 | return this.selectModalElements.find(x => x.id === nextId)!; 166 | } 167 | 168 | private getPreviousSelectModalElement(selectModalElement: SelectModalElement): SelectModalElement { 169 | let nextId = selectModalElement.id - 1; 170 | nextId = mod(nextId, this.selectModalElements.length); 171 | 172 | return this.selectModalElements.find(x => x.id === nextId)!; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/modals/SelectModalElement.ts: -------------------------------------------------------------------------------- 1 | import type { SelectModal } from './SelectModal'; 2 | 3 | export class SelectModalElement { 4 | selectModal: SelectModal; 5 | value: T; 6 | readonly id: number; 7 | element: HTMLDivElement; 8 | cssClass: string; 9 | activeClass: string; 10 | hoverClass: string; 11 | private active: boolean; 12 | private highlighted: boolean; 13 | 14 | constructor(value: T, parentElement: HTMLElement, id: number, selectModal: SelectModal, active: boolean = false) { 15 | this.value = value; 16 | this.id = id; 17 | this.active = active; 18 | this.selectModal = selectModal; 19 | 20 | this.cssClass = 'media-db-plugin-select-element'; 21 | this.activeClass = 'media-db-plugin-select-element-selected'; 22 | this.hoverClass = 'media-db-plugin-select-element-hover'; 23 | 24 | this.element = parentElement.createDiv({ cls: this.cssClass }); 25 | this.element.id = this.getHTMLId(); 26 | this.element.on('click', '#' + this.getHTMLId(), () => { 27 | this.setActive(!this.active); 28 | if (!this.selectModal.allowMultiSelect) { 29 | this.selectModal.disableAllOtherElements(this.id); 30 | } 31 | }); 32 | this.element.on('mouseenter', '#' + this.getHTMLId(), () => { 33 | this.setHighlighted(true); 34 | }); 35 | this.element.on('mouseleave', '#' + this.getHTMLId(), () => { 36 | this.setHighlighted(false); 37 | }); 38 | 39 | this.highlighted = false; 40 | } 41 | 42 | getHTMLId(): string { 43 | return `media-db-plugin-select-element-${this.id}`; 44 | } 45 | 46 | isHighlighted(): boolean { 47 | return this.highlighted; 48 | } 49 | 50 | setHighlighted(value: boolean): void { 51 | this.highlighted = value; 52 | if (this.highlighted) { 53 | this.addClass(this.hoverClass); 54 | this.selectModal.deHighlightAllOtherElements(this.id); 55 | } else { 56 | this.removeClass(this.hoverClass); 57 | } 58 | } 59 | 60 | isActive(): boolean { 61 | return this.active; 62 | } 63 | 64 | setActive(active: boolean): void { 65 | this.active = active; 66 | this.update(); 67 | } 68 | 69 | update(): void { 70 | if (this.active) { 71 | this.addClass(this.activeClass); 72 | } else { 73 | this.removeClass(this.activeClass); 74 | } 75 | } 76 | 77 | addClass(cssClass: string): void { 78 | if (!this.element.hasClass(cssClass)) { 79 | this.element.addClass(cssClass); 80 | } 81 | } 82 | 83 | removeClass(cssClass: string): void { 84 | if (this.element.hasClass(cssClass)) { 85 | this.element.removeClass(cssClass); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/models/BoardGameModel.ts: -------------------------------------------------------------------------------- 1 | import { MediaType } from '../utils/MediaType'; 2 | import type { ModelToData } from '../utils/Utils'; 3 | import { mediaDbTag, migrateObject } from '../utils/Utils'; 4 | import { MediaTypeModel } from './MediaTypeModel'; 5 | 6 | export type BoardGameData = ModelToData; 7 | 8 | export class BoardGameModel extends MediaTypeModel { 9 | genres: string[]; 10 | onlineRating: number; 11 | complexityRating: number; 12 | minPlayers: number; 13 | maxPlayers: number; 14 | playtime: string; 15 | publishers: string[]; 16 | image?: string; 17 | 18 | released: boolean; 19 | 20 | userData: { 21 | played: boolean; 22 | personalRating: number; 23 | }; 24 | 25 | constructor(obj: BoardGameData) { 26 | super(); 27 | 28 | this.genres = []; 29 | this.onlineRating = 0; 30 | this.complexityRating = 0; 31 | this.minPlayers = 0; 32 | this.maxPlayers = 0; 33 | this.playtime = ''; 34 | this.publishers = []; 35 | this.image = ''; 36 | 37 | this.released = false; 38 | 39 | this.userData = { 40 | played: false, 41 | personalRating: 0, 42 | }; 43 | 44 | migrateObject(this, obj, this); 45 | 46 | if (!obj.hasOwnProperty('userData')) { 47 | migrateObject(this.userData, obj, this.userData); 48 | } 49 | 50 | this.type = this.getMediaType(); 51 | } 52 | 53 | getTags(): string[] { 54 | return [mediaDbTag, 'boardgame']; 55 | } 56 | 57 | getMediaType(): MediaType { 58 | return MediaType.BoardGame; 59 | } 60 | 61 | getSummary(): string { 62 | return this.englishTitle + ' (' + this.year + ')'; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/models/BookModel.ts: -------------------------------------------------------------------------------- 1 | import { MediaType } from '../utils/MediaType'; 2 | import type { ModelToData } from '../utils/Utils'; 3 | import { mediaDbTag, migrateObject } from '../utils/Utils'; 4 | import { MediaTypeModel } from './MediaTypeModel'; 5 | 6 | export type BookData = ModelToData; 7 | 8 | export class BookModel extends MediaTypeModel { 9 | author: string; 10 | plot: string; 11 | pages: number; 12 | image: string; 13 | onlineRating: number; 14 | isbn: number; 15 | isbn13: number; 16 | 17 | released: boolean; 18 | 19 | userData: { 20 | read: boolean; 21 | lastRead: string; 22 | personalRating: number; 23 | }; 24 | 25 | constructor(obj: BookData) { 26 | super(); 27 | 28 | this.author = ''; 29 | this.plot = ''; 30 | this.pages = 0; 31 | this.image = ''; 32 | this.onlineRating = 0; 33 | this.isbn = 0; 34 | this.isbn13 = 0; 35 | 36 | this.released = false; 37 | 38 | this.userData = { 39 | read: false, 40 | lastRead: '', 41 | personalRating: 0, 42 | }; 43 | 44 | migrateObject(this, obj, this); 45 | 46 | if (!obj.hasOwnProperty('userData')) { 47 | migrateObject(this.userData, obj, this.userData); 48 | } 49 | 50 | this.type = this.getMediaType(); 51 | } 52 | 53 | getTags(): string[] { 54 | return [mediaDbTag, 'book']; 55 | } 56 | 57 | getMediaType(): MediaType { 58 | return MediaType.Book; 59 | } 60 | 61 | getSummary(): string { 62 | return this.englishTitle + ' (' + this.year + ') - ' + this.author; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/models/ComicMangaModel.ts: -------------------------------------------------------------------------------- 1 | import { MediaType } from '../utils/MediaType'; 2 | import type { ModelToData } from '../utils/Utils'; 3 | import { mediaDbTag, migrateObject } from '../utils/Utils'; 4 | import { MediaTypeModel } from './MediaTypeModel'; 5 | 6 | export type ComicMangaData = ModelToData; 7 | 8 | export class ComicMangaModel extends MediaTypeModel { 9 | plot: string; 10 | alternateTitles: string[]; 11 | genres: string[]; 12 | authors: string[]; 13 | chapters: number; 14 | volumes: number; 15 | onlineRating: number; 16 | image: string; 17 | 18 | released: boolean; 19 | status: string; 20 | publishers: string[]; 21 | publishedFrom: string; 22 | publishedTo: string; 23 | 24 | userData: { 25 | read: boolean; 26 | lastRead: string; 27 | personalRating: number; 28 | }; 29 | 30 | constructor(obj: ComicMangaData) { 31 | super(); 32 | 33 | this.plot = ''; 34 | this.alternateTitles = []; 35 | this.genres = []; 36 | this.authors = []; 37 | this.chapters = 0; 38 | this.volumes = 0; 39 | this.onlineRating = 0; 40 | this.image = ''; 41 | 42 | this.released = false; 43 | this.status = ''; 44 | this.publishers = []; 45 | this.publishedFrom = ''; 46 | this.publishedTo = ''; 47 | 48 | this.userData = { 49 | read: false, 50 | lastRead: '', 51 | personalRating: 0, 52 | }; 53 | 54 | migrateObject(this, obj, this); 55 | 56 | if (!obj.hasOwnProperty('userData')) { 57 | migrateObject(this.userData, obj, this.userData); 58 | } 59 | 60 | this.type = this.getMediaType(); 61 | } 62 | 63 | getTags(): string[] { 64 | return [mediaDbTag, 'manga', 'light-novel', 'comicbook']; 65 | } 66 | 67 | getMediaType(): MediaType { 68 | return MediaType.ComicManga; 69 | } 70 | 71 | getSummary(): string { 72 | return this.title + ' (' + this.year + ')'; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/models/GameModel.ts: -------------------------------------------------------------------------------- 1 | import { MediaType } from '../utils/MediaType'; 2 | import type { ModelToData } from '../utils/Utils'; 3 | import { mediaDbTag, migrateObject } from '../utils/Utils'; 4 | import { MediaTypeModel } from './MediaTypeModel'; 5 | 6 | export type GameData = ModelToData; 7 | 8 | export class GameModel extends MediaTypeModel { 9 | developers: string[]; 10 | publishers: string[]; 11 | genres: string[]; 12 | onlineRating: number; 13 | image: string; 14 | 15 | released: boolean; 16 | releaseDate: string; 17 | 18 | userData: { 19 | played: boolean; 20 | personalRating: number; 21 | }; 22 | 23 | constructor(obj: GameData) { 24 | super(); 25 | 26 | this.developers = []; 27 | this.publishers = []; 28 | this.genres = []; 29 | this.onlineRating = 0; 30 | this.image = ''; 31 | 32 | this.released = false; 33 | this.releaseDate = ''; 34 | 35 | this.userData = { 36 | played: false, 37 | personalRating: 0, 38 | }; 39 | 40 | migrateObject(this, obj, this); 41 | 42 | if (!obj.hasOwnProperty('userData')) { 43 | migrateObject(this.userData, obj, this.userData); 44 | } 45 | 46 | this.type = this.getMediaType(); 47 | } 48 | 49 | getTags(): string[] { 50 | return [mediaDbTag, 'game']; 51 | } 52 | 53 | getMediaType(): MediaType { 54 | return MediaType.Game; 55 | } 56 | 57 | getSummary(): string { 58 | return this.englishTitle + ' (' + this.year + ')'; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/models/MediaTypeModel.ts: -------------------------------------------------------------------------------- 1 | import type { MediaType } from '../utils/MediaType'; 2 | 3 | export abstract class MediaTypeModel { 4 | type: string; 5 | subType: string; 6 | title: string; 7 | englishTitle: string; 8 | year: string; 9 | dataSource: string; 10 | url: string; 11 | id: string; 12 | image?: string; 13 | 14 | userData: object; 15 | 16 | protected constructor() { 17 | this.type = ''; 18 | this.subType = ''; 19 | this.title = ''; 20 | this.englishTitle = ''; 21 | this.year = ''; 22 | this.dataSource = ''; 23 | this.url = ''; 24 | this.id = ''; 25 | this.image = ''; 26 | 27 | this.userData = {}; 28 | } 29 | 30 | abstract getMediaType(): MediaType; 31 | 32 | //a string that contains enough info to disambiguate from similar media 33 | abstract getSummary(): string; 34 | 35 | abstract getTags(): string[]; 36 | 37 | toMetaDataObject(): Record { 38 | return { ...this.getWithOutUserData(), ...this.userData, tags: this.getTags().join('/') }; 39 | } 40 | 41 | getWithOutUserData(): Record { 42 | const copy = structuredClone(this) as Record; 43 | delete copy.userData; 44 | return copy; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/models/MovieModel.ts: -------------------------------------------------------------------------------- 1 | import { MediaType } from '../utils/MediaType'; 2 | import type { ModelToData } from '../utils/Utils'; 3 | import { mediaDbTag, migrateObject } from '../utils/Utils'; 4 | import { MediaTypeModel } from './MediaTypeModel'; 5 | 6 | export type MovieData = ModelToData; 7 | 8 | export class MovieModel extends MediaTypeModel { 9 | plot: string; 10 | genres: string[]; 11 | director: string[]; 12 | writer: string[]; 13 | studio: string[]; 14 | duration: string; 15 | onlineRating: number; 16 | actors: string[]; 17 | image: string; 18 | 19 | released: boolean; 20 | streamingServices: string[]; 21 | premiere: string; 22 | 23 | userData: { 24 | watched: boolean; 25 | lastWatched: string; 26 | personalRating: number; 27 | }; 28 | 29 | constructor(obj: MovieData) { 30 | super(); 31 | 32 | this.plot = ''; 33 | this.genres = []; 34 | this.director = []; 35 | this.writer = []; 36 | this.studio = []; 37 | this.duration = ''; 38 | this.onlineRating = 0; 39 | this.actors = []; 40 | this.image = ''; 41 | 42 | this.released = false; 43 | this.streamingServices = []; 44 | this.premiere = ''; 45 | 46 | this.userData = { 47 | watched: false, 48 | lastWatched: '', 49 | personalRating: 0, 50 | }; 51 | 52 | migrateObject(this, obj, this); 53 | 54 | if (!obj.hasOwnProperty('userData')) { 55 | migrateObject(this.userData, obj, this.userData); 56 | } 57 | 58 | this.type = this.getMediaType(); 59 | } 60 | 61 | getTags(): string[] { 62 | return [mediaDbTag, 'tv', 'movie']; 63 | } 64 | 65 | getMediaType(): MediaType { 66 | return MediaType.Movie; 67 | } 68 | 69 | getSummary(): string { 70 | return this.englishTitle + ' (' + this.year + ')'; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/models/MusicReleaseModel.ts: -------------------------------------------------------------------------------- 1 | import { MediaType } from '../utils/MediaType'; 2 | import type { ModelToData } from '../utils/Utils'; 3 | import { mediaDbTag, migrateObject } from '../utils/Utils'; 4 | import { MediaTypeModel } from './MediaTypeModel'; 5 | 6 | export type MusicReleaseData = ModelToData; 7 | 8 | export class MusicReleaseModel extends MediaTypeModel { 9 | genres: string[]; 10 | artists: string[]; 11 | image: string; 12 | rating: number; 13 | 14 | userData: { 15 | personalRating: number; 16 | }; 17 | 18 | constructor(obj: MusicReleaseData) { 19 | super(); 20 | 21 | this.genres = []; 22 | this.artists = []; 23 | this.image = ''; 24 | this.rating = 0; 25 | this.userData = { 26 | personalRating: 0, 27 | }; 28 | 29 | migrateObject(this, obj, this); 30 | 31 | if (!obj.hasOwnProperty('userData')) { 32 | migrateObject(this.userData, obj, this.userData); 33 | } 34 | 35 | this.type = this.getMediaType(); 36 | } 37 | 38 | getTags(): string[] { 39 | return [mediaDbTag, 'music', this.subType]; 40 | } 41 | 42 | getMediaType(): MediaType { 43 | return MediaType.MusicRelease; 44 | } 45 | 46 | getSummary(): string { 47 | let summary = this.title + ' (' + this.year + ')'; 48 | if (this.artists.length > 0) summary += ' - ' + this.artists.join(', '); 49 | return summary; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/models/SeriesModel.ts: -------------------------------------------------------------------------------- 1 | import { MediaType } from '../utils/MediaType'; 2 | import type { ModelToData } from '../utils/Utils'; 3 | import { mediaDbTag, migrateObject } from '../utils/Utils'; 4 | import { MediaTypeModel } from './MediaTypeModel'; 5 | 6 | export type SeriesData = ModelToData; 7 | 8 | export class SeriesModel extends MediaTypeModel { 9 | plot: string; 10 | genres: string[]; 11 | writer: string[]; 12 | studio: string[]; 13 | episodes: number; 14 | duration: string; 15 | onlineRating: number; 16 | actors: string[]; 17 | image: string; 18 | 19 | released: boolean; 20 | streamingServices: string[]; 21 | airing: boolean; 22 | airedFrom: string; 23 | airedTo: string; 24 | 25 | userData: { 26 | watched: boolean; 27 | lastWatched: string; 28 | personalRating: number; 29 | }; 30 | 31 | constructor(obj: SeriesData) { 32 | super(); 33 | 34 | this.plot = ''; 35 | this.genres = []; 36 | this.writer = []; 37 | this.studio = []; 38 | this.episodes = 0; 39 | this.duration = ''; 40 | this.onlineRating = 0; 41 | this.actors = []; 42 | this.image = ''; 43 | 44 | this.released = false; 45 | this.streamingServices = []; 46 | this.airing = false; 47 | this.airedFrom = ''; 48 | this.airedTo = ''; 49 | 50 | this.userData = { 51 | watched: false, 52 | lastWatched: '', 53 | personalRating: 0, 54 | }; 55 | 56 | migrateObject(this, obj, this); 57 | 58 | if (!obj.hasOwnProperty('userData')) { 59 | migrateObject(this.userData, obj, this.userData); 60 | } 61 | 62 | this.type = this.getMediaType(); 63 | } 64 | 65 | getTags(): string[] { 66 | return [mediaDbTag, 'tv', 'series']; 67 | } 68 | 69 | getMediaType(): MediaType { 70 | return MediaType.Series; 71 | } 72 | 73 | getSummary(): string { 74 | return this.title + ' (' + this.year + ')'; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/models/WikiModel.ts: -------------------------------------------------------------------------------- 1 | import { MediaType } from '../utils/MediaType'; 2 | import type { ModelToData } from '../utils/Utils'; 3 | import { mediaDbTag, migrateObject } from '../utils/Utils'; 4 | import { MediaTypeModel } from './MediaTypeModel'; 5 | 6 | export type WikiData = ModelToData; 7 | 8 | export class WikiModel extends MediaTypeModel { 9 | wikiUrl: string; 10 | lastUpdated: string; 11 | length: number; 12 | article: string; 13 | 14 | userData: Record; 15 | 16 | constructor(obj: WikiData) { 17 | super(); 18 | 19 | this.wikiUrl = ''; 20 | this.lastUpdated = ''; 21 | this.length = 0; 22 | this.article = ''; 23 | this.userData = {}; 24 | 25 | migrateObject(this, obj, this); 26 | 27 | if (!obj.hasOwnProperty('userData')) { 28 | migrateObject(this.userData, obj, this.userData); 29 | } 30 | 31 | this.type = this.getMediaType(); 32 | } 33 | 34 | getTags(): string[] { 35 | return [mediaDbTag, 'wiki']; 36 | } 37 | 38 | getMediaType(): MediaType { 39 | return MediaType.Wiki; 40 | } 41 | 42 | override getWithOutUserData(): Record { 43 | const copy = structuredClone(this) as Record; 44 | delete copy.userData; 45 | delete copy.article; 46 | return copy; 47 | } 48 | 49 | getSummary(): string { 50 | return this.title; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/settings/Icon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | 20 | {#if iconName.length > 0} 21 |
22 |
23 |
24 | {/if} 25 | 26 | 40 | -------------------------------------------------------------------------------- /src/settings/PropertyMapper.ts: -------------------------------------------------------------------------------- 1 | import type MediaDbPlugin from '../main'; 2 | import { MEDIA_TYPES } from '../utils/MediaTypeManager'; 3 | import { PropertyMappingOption } from './PropertyMapping'; 4 | 5 | export class PropertyMapper { 6 | plugin: MediaDbPlugin; 7 | 8 | constructor(plugin: MediaDbPlugin) { 9 | this.plugin = plugin; 10 | } 11 | 12 | /** 13 | * Converts an object using the conversion rules for its type. 14 | * Returns an unaltered object if object.type is null or undefined or if there are no conversion rules for the type. 15 | * 16 | * @param obj 17 | */ 18 | convertObject(obj: Record): Record { 19 | if (!obj.hasOwnProperty('type')) { 20 | return obj; 21 | } 22 | 23 | // console.log(obj.type); 24 | 25 | if (MEDIA_TYPES.filter(x => x.toString() == obj.type).length < 1) { 26 | return obj; 27 | } 28 | 29 | // @ts-ignore 30 | const propertyMappings = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type).properties; 31 | 32 | const newObj: Record = {}; 33 | 34 | for (const [key, value] of Object.entries(obj)) { 35 | for (const propertyMapping of propertyMappings) { 36 | if (propertyMapping.property === key) { 37 | if (propertyMapping.mapping === PropertyMappingOption.Map) { 38 | // @ts-ignore 39 | newObj[propertyMapping.newProperty] = value; 40 | } else if (propertyMapping.mapping === PropertyMappingOption.Remove) { 41 | // do nothing 42 | } else if (propertyMapping.mapping === PropertyMappingOption.Default) { 43 | // @ts-ignore 44 | newObj[key] = value; 45 | } 46 | break; 47 | } 48 | } 49 | } 50 | 51 | return newObj; 52 | } 53 | 54 | /** 55 | * Converts an object back using the conversion rules for its type. 56 | * Returns an unaltered object if object.type is null or undefined or if there are no conversion rules for the type. 57 | * 58 | * @param obj 59 | */ 60 | convertObjectBack(obj: Record): Record { 61 | if (!obj.hasOwnProperty('type')) { 62 | return obj; 63 | } 64 | 65 | if (obj.type === 'manga') { 66 | obj.type = 'comicManga'; 67 | console.debug(`MDB | updated metadata type`, obj.type); 68 | } 69 | if (MEDIA_TYPES.contains(obj.type as any)) { 70 | return obj; 71 | } 72 | 73 | const propertyMappings = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type)?.properties ?? []; 74 | 75 | const originalObj: Record = {}; 76 | 77 | objLoop: for (const [key, value] of Object.entries(obj)) { 78 | // first try if it is a normal property 79 | for (const propertyMapping of propertyMappings) { 80 | if (propertyMapping.property === key) { 81 | // @ts-ignore 82 | originalObj[key] = value; 83 | 84 | continue objLoop; 85 | } 86 | } 87 | 88 | // otherwise see if it is a mapped property 89 | for (const propertyMapping of propertyMappings) { 90 | if (propertyMapping.newProperty === key) { 91 | // @ts-ignore 92 | originalObj[propertyMapping.property] = value; 93 | 94 | continue objLoop; 95 | } 96 | } 97 | } 98 | 99 | return originalObj; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/settings/PropertyMapping.ts: -------------------------------------------------------------------------------- 1 | import type { MediaType } from '../utils/MediaType'; 2 | import { containsOnlyLettersAndUnderscores, PropertyMappingNameConflictError, PropertyMappingValidationError } from '../utils/Utils'; 3 | 4 | export enum PropertyMappingOption { 5 | Default = 'default', 6 | Map = 'remap', 7 | Remove = 'remove', 8 | } 9 | 10 | export const propertyMappingOptions = [PropertyMappingOption.Default, PropertyMappingOption.Map, PropertyMappingOption.Remove]; 11 | 12 | export class PropertyMappingModel { 13 | type: MediaType; 14 | properties: PropertyMapping[]; 15 | 16 | constructor(type: MediaType, properties?: PropertyMapping[]) { 17 | this.type = type; 18 | this.properties = properties ?? []; 19 | } 20 | 21 | validate(): { res: boolean; err?: Error } { 22 | console.debug(`MDB | validated property mappings for ${this.type}`); 23 | 24 | // check properties 25 | for (const property of this.properties) { 26 | const propertyValidation = property.validate(); 27 | if (!propertyValidation.res) { 28 | return { 29 | res: false, 30 | err: propertyValidation.err, 31 | }; 32 | } 33 | } 34 | 35 | // check for name collisions 36 | for (const property of this.getMappedProperties()) { 37 | const propertiesWithSameTarget = this.getMappedProperties().filter(x => x.newProperty === property.newProperty); 38 | if (propertiesWithSameTarget.length === 0) { 39 | // if we get there, then something in this code is wrong 40 | } else if (propertiesWithSameTarget.length === 1) { 41 | // all good 42 | } else { 43 | // two or more properties are mapped to the same property 44 | return { 45 | res: false, 46 | err: new PropertyMappingNameConflictError( 47 | `Multiple remapped properties (${propertiesWithSameTarget.map(x => x.toString()).toString()}) may not share the same name.`, 48 | ), 49 | }; 50 | } 51 | } 52 | // remapped properties may not have the same name as any original property 53 | for (const property of this.getMappedProperties()) { 54 | const propertiesWithSameTarget = this.properties.filter(x => x.newProperty === property.property); 55 | if (propertiesWithSameTarget.length === 0) { 56 | // all good 57 | } else { 58 | // a mapped property shares the same name with an original property 59 | return { 60 | res: false, 61 | err: new PropertyMappingNameConflictError(`Remapped property (${property}) may not share it's new name with an existing property.`), 62 | }; 63 | } 64 | } 65 | 66 | return { 67 | res: true, 68 | }; 69 | } 70 | 71 | getMappedProperties(): PropertyMapping[] { 72 | return this.properties.filter(x => x.mapping === PropertyMappingOption.Map); 73 | } 74 | 75 | copy(): PropertyMappingModel { 76 | const copy = new PropertyMappingModel(this.type); 77 | for (const property of this.properties) { 78 | const propertyCopy = new PropertyMapping(property.property, property.newProperty, property.mapping, property.locked); 79 | copy.properties.push(propertyCopy); 80 | } 81 | return copy; 82 | } 83 | } 84 | 85 | export class PropertyMapping { 86 | property: string; 87 | newProperty: string; 88 | locked: boolean; 89 | mapping: PropertyMappingOption; 90 | 91 | constructor(property: string, newProperty: string, mapping: PropertyMappingOption, locked?: boolean) { 92 | this.property = property; 93 | this.newProperty = newProperty; 94 | this.mapping = mapping; 95 | this.locked = locked ?? false; 96 | } 97 | 98 | validate(): { res: boolean; err?: Error } { 99 | // locked property may only be default 100 | if (this.locked) { 101 | if (this.mapping === PropertyMappingOption.Remove) { 102 | return { 103 | res: false, 104 | err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": locked property may not be removed.`), 105 | }; 106 | } 107 | if (this.mapping === PropertyMappingOption.Map) { 108 | return { 109 | res: false, 110 | err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": locked property may not be remapped.`), 111 | }; 112 | } 113 | } 114 | 115 | if (this.mapping === PropertyMappingOption.Default) { 116 | return { res: true }; 117 | } 118 | if (this.mapping === PropertyMappingOption.Remove) { 119 | return { res: true }; 120 | } 121 | 122 | if (!this.property || !containsOnlyLettersAndUnderscores(this.property)) { 123 | return { 124 | res: false, 125 | err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": property may not be empty and only contain letters and underscores.`), 126 | }; 127 | } 128 | 129 | if (!this.newProperty || !containsOnlyLettersAndUnderscores(this.newProperty)) { 130 | return { 131 | res: false, 132 | err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": new property may not be empty and only contain letters and underscores.`), 133 | }; 134 | } 135 | 136 | return { 137 | res: true, 138 | }; 139 | } 140 | 141 | toString(): string { 142 | if (this.mapping === PropertyMappingOption.Default) { 143 | return this.property; 144 | } else if (this.mapping === PropertyMappingOption.Map) { 145 | return `${this.property} -> ${this.newProperty}`; 146 | } else if (this.mapping === PropertyMappingOption.Remove) { 147 | return `remove ${this.property}`; 148 | } 149 | 150 | return this.property; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/settings/PropertyMappingModelComponent.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
23 |
{capitalizeFirstLetter(model.type)}
24 |
25 | {#each model.properties as property} 26 |
27 |
28 |
{property.property}
29 |
30 | {#if property.locked} 31 |
property cannot be remapped
32 | {:else} 33 | 40 | 41 | {#if property.mapping === PropertyMappingOption.Map} 42 | 43 |
44 | 45 |
46 | {/if} 47 | {/if} 48 |
49 | {/each} 50 |
51 | {#if !validationResult?.res} 52 |
53 | {validationResult?.err?.message} 54 |
55 | {/if} 56 | 63 |
64 | 65 | 67 | -------------------------------------------------------------------------------- /src/settings/PropertyMappingModelsComponent.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 | {#each models as model} 15 | 16 | {/each} 17 | 18 | 27 |
28 | 29 | 31 | -------------------------------------------------------------------------------- /src/settings/suggesters/FileSuggest.ts: -------------------------------------------------------------------------------- 1 | import type { TAbstractFile } from 'obsidian'; 2 | import { TFile } from 'obsidian'; 3 | import { TextInputSuggest } from './Suggest'; 4 | 5 | export class FileSuggest extends TextInputSuggest { 6 | getSuggestions(inputStr: string): TFile[] { 7 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 8 | const files: TFile[] = []; 9 | const lowerCaseInputStr = inputStr.toLowerCase(); 10 | 11 | abstractFiles.forEach((file: TAbstractFile) => { 12 | if (file instanceof TFile && file.name.toLowerCase().contains(lowerCaseInputStr)) { 13 | files.push(file); 14 | } 15 | }); 16 | 17 | return files; 18 | } 19 | 20 | renderSuggestion(file: TFile, el: HTMLElement): void { 21 | el.setText(file.path); 22 | } 23 | 24 | selectSuggestion(file: TFile): void { 25 | this.inputEl.value = file.path; 26 | this.inputEl.trigger('input'); 27 | this.close(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/settings/suggesters/FolderSuggest.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import type { TAbstractFile } from 'obsidian'; 4 | import { TFolder } from 'obsidian'; 5 | import { TextInputSuggest } from './Suggest'; 6 | 7 | export class FolderSuggest extends TextInputSuggest { 8 | getSuggestions(inputStr: string): TFolder[] { 9 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 10 | const folders: TFolder[] = []; 11 | const lowerCaseInputStr = inputStr.toLowerCase(); 12 | 13 | abstractFiles.forEach((folder: TAbstractFile) => { 14 | if (folder instanceof TFolder && folder.path.toLowerCase().contains(lowerCaseInputStr)) { 15 | folders.push(folder); 16 | } 17 | }); 18 | 19 | return folders; 20 | } 21 | 22 | renderSuggestion(file: TFolder, el: HTMLElement): void { 23 | el.setText(file.path); 24 | } 25 | 26 | selectSuggestion(file: TFolder): void { 27 | this.inputEl.value = file.path; 28 | this.inputEl.trigger('input'); 29 | this.close(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/settings/suggesters/Suggest.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import type { Instance as PopperInstance } from '@popperjs/core'; 4 | import { createPopper } from '@popperjs/core'; 5 | import type { App, ISuggestOwner } from 'obsidian'; 6 | import { Scope } from 'obsidian'; 7 | import { wrapAround } from 'src/utils/Utils'; 8 | 9 | export class Suggest { 10 | private owner: ISuggestOwner; 11 | private values: T[]; 12 | private suggestions: HTMLElement[]; 13 | private selectedItem: number; 14 | private containerEl: HTMLElement; 15 | 16 | constructor(owner: ISuggestOwner, containerEl: HTMLElement, scope: Scope) { 17 | this.owner = owner; 18 | this.containerEl = containerEl; 19 | this.values = []; 20 | this.suggestions = []; 21 | this.selectedItem = 0; 22 | 23 | containerEl.on('click', '.suggestion-item', (e, el) => this.onSuggestionClick(e, el)); 24 | containerEl.on('mousemove', '.suggestion-item', (e, el) => this.onSuggestionMouseover(e, el)); 25 | 26 | scope.register([], 'ArrowUp', event => { 27 | if (!event.isComposing) { 28 | this.setSelectedItem(this.selectedItem - 1, true); 29 | return false; 30 | } 31 | return undefined; 32 | }); 33 | 34 | scope.register([], 'ArrowDown', event => { 35 | if (!event.isComposing) { 36 | this.setSelectedItem(this.selectedItem + 1, true); 37 | return false; 38 | } 39 | return undefined; 40 | }); 41 | 42 | scope.register([], 'Enter', event => { 43 | if (!event.isComposing) { 44 | this.useSelectedItem(event); 45 | return false; 46 | } 47 | return undefined; 48 | }); 49 | } 50 | 51 | onSuggestionClick(event: MouseEvent, el: HTMLElement): void { 52 | event.preventDefault(); 53 | 54 | const item = this.suggestions.indexOf(el); 55 | this.setSelectedItem(item, false); 56 | this.useSelectedItem(event); 57 | } 58 | 59 | onSuggestionMouseover(_event: MouseEvent, el: HTMLElement): void { 60 | const item = this.suggestions.indexOf(el); 61 | this.setSelectedItem(item, false); 62 | } 63 | 64 | setSuggestions(values: T[]): void { 65 | this.containerEl.empty(); 66 | const suggestionEls: HTMLDivElement[] = []; 67 | 68 | values.forEach(value => { 69 | const suggestionEl = this.containerEl.createDiv('suggestion-item'); 70 | this.owner.renderSuggestion(value, suggestionEl); 71 | suggestionEls.push(suggestionEl); 72 | }); 73 | 74 | this.values = values; 75 | this.suggestions = suggestionEls; 76 | this.setSelectedItem(0, false); 77 | } 78 | 79 | useSelectedItem(event: MouseEvent | KeyboardEvent): void { 80 | const currentValue = this.values[this.selectedItem]; 81 | if (currentValue) { 82 | this.owner.selectSuggestion(currentValue, event); 83 | } 84 | } 85 | 86 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean): void { 87 | const normalizedIndex = this.suggestions.length > 0 ? wrapAround(selectedIndex, this.suggestions.length) : 0; 88 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 89 | const selectedSuggestion = this.suggestions[normalizedIndex]; 90 | 91 | prevSelectedSuggestion?.removeClass('is-selected'); 92 | selectedSuggestion?.addClass('is-selected'); 93 | 94 | this.selectedItem = normalizedIndex; 95 | 96 | if (scrollIntoView) { 97 | selectedSuggestion.scrollIntoView(false); 98 | } 99 | } 100 | } 101 | 102 | export abstract class TextInputSuggest implements ISuggestOwner { 103 | protected app: App; 104 | protected inputEl: HTMLInputElement; 105 | 106 | private popper?: PopperInstance; 107 | private scope: Scope; 108 | private suggestEl: HTMLElement; 109 | private suggest: Suggest; 110 | 111 | constructor(app: App, inputEl: HTMLInputElement) { 112 | this.app = app; 113 | this.inputEl = inputEl; 114 | this.scope = new Scope(); 115 | 116 | this.suggestEl = createDiv('suggestion-container'); 117 | const suggestion = this.suggestEl.createDiv('suggestion'); 118 | this.suggest = new Suggest(this, suggestion, this.scope); 119 | 120 | this.scope.register([], 'Escape', this.close.bind(this)); 121 | 122 | this.inputEl.addEventListener('input', this.onInputChanged.bind(this)); 123 | this.inputEl.addEventListener('focus', this.onInputChanged.bind(this)); 124 | this.inputEl.addEventListener('blur', this.close.bind(this)); 125 | this.suggestEl.on('mousedown', '.suggestion-container', (event: MouseEvent) => { 126 | event.preventDefault(); 127 | }); 128 | } 129 | 130 | onInputChanged(): void { 131 | const inputStr = this.inputEl.value; 132 | const suggestions = this.getSuggestions(inputStr); 133 | 134 | if (suggestions.length > 0) { 135 | this.suggest.setSuggestions(suggestions); 136 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 137 | this.open((this.app as any).dom.appContainerEl, this.inputEl); 138 | } 139 | } 140 | 141 | open(container: HTMLElement, inputEl: HTMLElement): void { 142 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 143 | (this.app as any).keymap.pushScope(this.scope); 144 | 145 | container.appendChild(this.suggestEl); 146 | this.popper = createPopper(inputEl, this.suggestEl, { 147 | placement: 'bottom-start', 148 | modifiers: [ 149 | { 150 | name: 'sameWidth', 151 | enabled: true, 152 | fn: ({ state, instance }): void => { 153 | // Note: positioning needs to be calculated twice - 154 | // first pass - positioning it according to the width of the popper 155 | // second pass - position it with the width bound to the reference element 156 | // we need to early exit to avoid an infinite loop 157 | const targetWidth = `${state.rects.reference.width}px`; 158 | if (state.styles.popper.width === targetWidth) { 159 | return; 160 | } 161 | state.styles.popper.width = targetWidth; 162 | instance.update(); 163 | }, 164 | phase: 'beforeWrite', 165 | requires: ['computeStyles'], 166 | }, 167 | ], 168 | }); 169 | } 170 | 171 | close(): void { 172 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 173 | (this.app as any).keymap.popScope(this.scope); 174 | 175 | this.suggest.setSuggestions([]); 176 | this.popper?.destroy(); 177 | this.suggestEl.detach(); 178 | } 179 | 180 | abstract getSuggestions(inputStr: string): T[]; 181 | 182 | abstract renderSuggestion(item: T, el: HTMLElement): void; 183 | 184 | abstract selectSuggestion(item: T): void; 185 | } 186 | -------------------------------------------------------------------------------- /src/utils/DateFormatter.ts: -------------------------------------------------------------------------------- 1 | import { moment } from 'obsidian'; 2 | 3 | export class DateFormatter { 4 | toFormat: string; 5 | locale: string; 6 | 7 | constructor() { 8 | this.toFormat = 'YYYY-MM-DD'; 9 | // get locale of this machine (e.g. en, en-gb, de, fr, etc.) 10 | this.locale = new Intl.DateTimeFormat().resolvedOptions().locale; 11 | } 12 | 13 | setFormat(format: string): void { 14 | this.toFormat = format; 15 | } 16 | 17 | getPreview(format?: string): string { 18 | const today = moment(); 19 | 20 | if (!format) { 21 | format = this.toFormat; 22 | } 23 | 24 | return today.locale(this.locale).format(format); 25 | } 26 | 27 | /** 28 | * Tries to format a given date string with the currently set date format. 29 | * You can set a date format by calling `setFormat()`. 30 | * 31 | * @param dateString the date string to be formatted 32 | * @param dateFormat the current format of `dateString`. When this is `null` and the actual format of the 33 | * given date string is not `C2822` or `ISO` format, this function will try to guess the format by using the native `Date` module. 34 | * @param locale the locale of `dateString`. This is needed when `dateString` includes a month or day name and its locale format differs 35 | * from the locale of this machine. 36 | * @returns formatted date string or null if `dateString` is not a valid date 37 | */ 38 | format(dateString: string, dateFormat?: string, locale: string = 'en'): string | null { 39 | if (!dateString) { 40 | return null; 41 | } 42 | 43 | let date: moment.Moment; 44 | 45 | if (!dateFormat) { 46 | // reading date formats other then C2822 or ISO with moment is deprecated 47 | // see https://momentjs.com/docs/#/parsing/string/ 48 | if (this.hasMomentFormat(dateString)) { 49 | // expect C2822 or ISO format 50 | date = moment(dateString); 51 | } else { 52 | // try to read date string with native Date 53 | date = moment(new Date(dateString)); 54 | } 55 | } else { 56 | date = moment(dateString, dateFormat, locale); 57 | } 58 | 59 | // format date (if it is valid) 60 | return date.isValid() ? date.locale(this.locale).format(this.toFormat) : null; 61 | } 62 | 63 | private hasMomentFormat(dateString: string): boolean { 64 | const date = moment(dateString, true); // strict mode 65 | return date.isValid(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/MediaType.ts: -------------------------------------------------------------------------------- 1 | export enum MediaType { 2 | Movie = 'movie', 3 | Series = 'series', 4 | ComicManga = 'comicManga', 5 | Game = 'game', 6 | MusicRelease = 'musicRelease', 7 | Wiki = 'wiki', 8 | BoardGame = 'boardgame', 9 | Book = 'book', 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/MediaTypeManager.ts: -------------------------------------------------------------------------------- 1 | import type { App, TAbstractFile, TFile } from 'obsidian'; 2 | import { TFolder } from 'obsidian'; 3 | import { BoardGameModel } from '../models/BoardGameModel'; 4 | import { BookModel } from '../models/BookModel'; 5 | import { GameModel } from '../models/GameModel'; 6 | import { ComicMangaModel } from '../models/ComicMangaModel'; 7 | import type { MediaTypeModel } from '../models/MediaTypeModel'; 8 | import { MovieModel } from '../models/MovieModel'; 9 | import { MusicReleaseModel } from '../models/MusicReleaseModel'; 10 | import { SeriesModel } from '../models/SeriesModel'; 11 | import { WikiModel } from '../models/WikiModel'; 12 | import type { MediaDbPluginSettings } from '../settings/Settings'; 13 | import { MediaType } from './MediaType'; 14 | import { replaceTags } from './Utils'; 15 | 16 | export const MEDIA_TYPES: MediaType[] = [ 17 | MediaType.Movie, 18 | MediaType.Series, 19 | MediaType.ComicManga, 20 | MediaType.Game, 21 | MediaType.Wiki, 22 | MediaType.MusicRelease, 23 | MediaType.BoardGame, 24 | MediaType.Book, 25 | ]; 26 | 27 | export class MediaTypeManager { 28 | mediaFileNameTemplateMap: Map; 29 | mediaTemplateMap: Map; 30 | mediaFolderMap: Map; 31 | 32 | constructor() { 33 | this.mediaFileNameTemplateMap = new Map(); 34 | this.mediaTemplateMap = new Map(); 35 | this.mediaFolderMap = new Map(); 36 | } 37 | 38 | updateTemplates(settings: MediaDbPluginSettings): void { 39 | this.mediaFileNameTemplateMap = new Map(); 40 | this.mediaFileNameTemplateMap.set(MediaType.Movie, settings.movieFileNameTemplate); 41 | this.mediaFileNameTemplateMap.set(MediaType.Series, settings.seriesFileNameTemplate); 42 | this.mediaFileNameTemplateMap.set(MediaType.ComicManga, settings.mangaFileNameTemplate); 43 | this.mediaFileNameTemplateMap.set(MediaType.Game, settings.gameFileNameTemplate); 44 | this.mediaFileNameTemplateMap.set(MediaType.Wiki, settings.wikiFileNameTemplate); 45 | this.mediaFileNameTemplateMap.set(MediaType.MusicRelease, settings.musicReleaseFileNameTemplate); 46 | this.mediaFileNameTemplateMap.set(MediaType.BoardGame, settings.boardgameFileNameTemplate); 47 | this.mediaFileNameTemplateMap.set(MediaType.Book, settings.bookFileNameTemplate); 48 | 49 | this.mediaTemplateMap = new Map(); 50 | this.mediaTemplateMap.set(MediaType.Movie, settings.movieTemplate); 51 | this.mediaTemplateMap.set(MediaType.Series, settings.seriesTemplate); 52 | this.mediaTemplateMap.set(MediaType.ComicManga, settings.mangaTemplate); 53 | this.mediaTemplateMap.set(MediaType.Game, settings.gameTemplate); 54 | this.mediaTemplateMap.set(MediaType.Wiki, settings.wikiTemplate); 55 | this.mediaTemplateMap.set(MediaType.MusicRelease, settings.musicReleaseTemplate); 56 | this.mediaTemplateMap.set(MediaType.BoardGame, settings.boardgameTemplate); 57 | this.mediaTemplateMap.set(MediaType.Book, settings.bookTemplate); 58 | } 59 | 60 | updateFolders(settings: MediaDbPluginSettings): void { 61 | this.mediaFolderMap = new Map(); 62 | this.mediaFolderMap.set(MediaType.Movie, settings.movieFolder); 63 | this.mediaFolderMap.set(MediaType.Series, settings.seriesFolder); 64 | this.mediaFolderMap.set(MediaType.ComicManga, settings.mangaFolder); 65 | this.mediaFolderMap.set(MediaType.Game, settings.gameFolder); 66 | this.mediaFolderMap.set(MediaType.Wiki, settings.wikiFolder); 67 | this.mediaFolderMap.set(MediaType.MusicRelease, settings.musicReleaseFolder); 68 | this.mediaFolderMap.set(MediaType.BoardGame, settings.boardgameFolder); 69 | this.mediaFolderMap.set(MediaType.Book, settings.bookFolder); 70 | } 71 | 72 | getFileName(mediaTypeModel: MediaTypeModel): string { 73 | // Ignore undefined tags since some search APIs do not return all properties in the model and produce clean file names even if errors occur 74 | return replaceTags(this.mediaFileNameTemplateMap.get(mediaTypeModel.getMediaType())!, mediaTypeModel, true); 75 | } 76 | 77 | async getTemplate(mediaTypeModel: MediaTypeModel, app: App): Promise { 78 | const templateFilePath = this.mediaTemplateMap.get(mediaTypeModel.getMediaType()); 79 | 80 | if (!templateFilePath) { 81 | return ''; 82 | } 83 | 84 | let templateFile = app.vault.getAbstractFileByPath(templateFilePath) ?? undefined; 85 | 86 | // WARNING: This was previously selected by filename, but that could lead to collisions and unwanted effects. 87 | // This now falls back to the previous method if no file is found 88 | if (!templateFile || templateFile instanceof TFolder) { 89 | templateFile = app.vault 90 | .getFiles() 91 | .filter((f: TFile) => f.name === templateFilePath) 92 | .first(); 93 | 94 | if (!templateFile) { 95 | return ''; 96 | } 97 | } 98 | 99 | const template = await app.vault.cachedRead(templateFile as TFile); 100 | // console.log(template); 101 | return replaceTags(template, mediaTypeModel); 102 | } 103 | 104 | async getFolder(mediaTypeModel: MediaTypeModel, app: App): Promise { 105 | let folderPath = this.mediaFolderMap.get(mediaTypeModel.getMediaType()); 106 | 107 | if (!folderPath) { 108 | folderPath = `/`; 109 | } 110 | // console.log(folderPath); 111 | 112 | if (!(await app.vault.adapter.exists(folderPath))) { 113 | await app.vault.createFolder(folderPath); 114 | } 115 | const folder = app.vault.getAbstractFileByPath(folderPath); 116 | 117 | if (!(folder instanceof TFolder)) { 118 | throw Error(`Expected ${folder} to be instance of TFolder`); 119 | } 120 | 121 | return folder; 122 | } 123 | 124 | /** 125 | * Takes an object and a MediaType and turns the object into an instance of a MediaTypeModel corresponding to the MediaType passed in. 126 | * 127 | * @param obj 128 | * @param mediaType 129 | */ 130 | createMediaTypeModelFromMediaType(obj: any, mediaType: MediaType): MediaTypeModel { 131 | if (mediaType === MediaType.Movie) { 132 | return new MovieModel(obj); 133 | } else if (mediaType === MediaType.Series) { 134 | return new SeriesModel(obj); 135 | } else if (mediaType === MediaType.ComicManga) { 136 | return new ComicMangaModel(obj); 137 | } else if (mediaType === MediaType.Game) { 138 | return new GameModel(obj); 139 | } else if (mediaType === MediaType.Wiki) { 140 | return new WikiModel(obj); 141 | } else if (mediaType === MediaType.MusicRelease) { 142 | return new MusicReleaseModel(obj); 143 | } else if (mediaType === MediaType.BoardGame) { 144 | return new BoardGameModel(obj); 145 | } else if (mediaType === MediaType.Book) { 146 | return new BookModel(obj); 147 | } 148 | 149 | throw new Error(`Unknown media type: ${mediaType}`); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/utils/ModalHelper.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from 'obsidian'; 2 | import { MediaDbPreviewModal } from 'src/modals/MediaDbPreviewModal'; 3 | import type MediaDbPlugin from '../main'; 4 | import { MediaDbAdvancedSearchModal } from '../modals/MediaDbAdvancedSearchModal'; 5 | import { MediaDbIdSearchModal } from '../modals/MediaDbIdSearchModal'; 6 | import { MediaDbSearchModal } from '../modals/MediaDbSearchModal'; 7 | import { MediaDbSearchResultModal } from '../modals/MediaDbSearchResultModal'; 8 | import type { MediaTypeModel } from '../models/MediaTypeModel'; 9 | import type { MediaType } from './MediaType'; 10 | 11 | export enum ModalResultCode { 12 | SUCCESS = 'SUCCESS', 13 | SKIP = 'SKIP', 14 | CLOSE = 'CLOSE', 15 | ERROR = 'ERROR', 16 | } 17 | 18 | type ModalResult = 19 | | { 20 | code: ModalResultCode.CLOSE; 21 | } 22 | | { 23 | code: ModalResultCode.ERROR; 24 | error: Error; 25 | } 26 | | { 27 | code: ModalResultCode.SUCCESS; 28 | data: T; 29 | }; 30 | 31 | type SkippableModalResult = 32 | | ModalResult 33 | | { 34 | code: ModalResultCode.SKIP; 35 | }; 36 | 37 | /** 38 | * Object containing the data {@link ModalHelper.createSearchModal} returns. 39 | * On {@link ModalResultCode.SUCCESS} this contains {@link SearchModalData}. 40 | * On {@link ModalResultCode.ERROR} this contains a reference to that error. 41 | */ 42 | export type SearchModalResult = ModalResult; 43 | 44 | /** 45 | * Object containing the data {@link ModalHelper.createAdvancedSearchModal} returns. 46 | * On {@link ModalResultCode.SUCCESS} this contains {@link AdvancedSearchModalData}. 47 | * On {@link ModalResultCode.ERROR} this contains a reference to that error. 48 | */ 49 | export type AdvancedSearchModalResult = ModalResult; 50 | 51 | /** 52 | * Object containing the data {@link ModalHelper.createIdSearchModal} returns. 53 | * On {@link ModalResultCode.SUCCESS} this contains {@link IdSearchModalData}. 54 | * On {@link ModalResultCode.ERROR} this contains a reference to that error. 55 | */ 56 | export type IdSearchModalResult = ModalResult; 57 | 58 | /** 59 | * Object containing the data {@link ModalHelper.createSelectModal} returns. 60 | * On {@link ModalResultCode.SUCCESS} this contains {@link SelectModalData}. 61 | * On {@link ModalResultCode.ERROR} this contains a reference to that error. 62 | */ 63 | export type SelectModalResult = SkippableModalResult; 64 | 65 | /** 66 | * Object containing the data {@link ModalHelper.createPreviewModal} returns. 67 | * On {@link ModalResultCode.SUCCESS} this contains {@link PreviewModalData}. 68 | * On {@link ModalResultCode.ERROR} this contains a reference to that error. 69 | */ 70 | export type PreviewModalResult = ModalResult; 71 | 72 | /** 73 | * The data the search modal returns. 74 | * - query: the query string 75 | * - types: the selected APIs 76 | */ 77 | export interface SearchModalData { 78 | query: string; 79 | types: MediaType[]; 80 | } 81 | 82 | /** 83 | * The data the advanced search modal returns. 84 | * - query: the query string 85 | * - apis: the selected APIs 86 | */ 87 | export interface AdvancedSearchModalData { 88 | query: string; 89 | apis: string[]; 90 | } 91 | 92 | /** 93 | * The data the id search modal returns. 94 | * - query: the query string 95 | * - apis: the selected APIs 96 | */ 97 | export interface IdSearchModalData { 98 | query: string; 99 | api: string; 100 | } 101 | 102 | /** 103 | * The data the select modal returns. 104 | * - selected: the selected items 105 | */ 106 | export interface SelectModalData { 107 | selected: MediaTypeModel[]; 108 | } 109 | 110 | /** 111 | * The data the preview modal returns. 112 | * - confirmed: whether the selected element has been confirmed 113 | */ 114 | export interface PreviewModalData { 115 | confirmed: boolean; 116 | } 117 | 118 | /** 119 | * Options for the search modal. 120 | * - modalTitle: the title of the modal 121 | * - preselectedTypes: a list of preselected Types 122 | * - prefilledSearchString: prefilled query 123 | */ 124 | export interface SearchModalOptions { 125 | modalTitle?: string; 126 | preselectedTypes?: MediaType[]; 127 | prefilledSearchString?: string; 128 | } 129 | 130 | /** 131 | * Options for the advanced search modal. 132 | * - modalTitle: the title of the modal 133 | * - preselectedAPIs: a list of preselected APIs 134 | * - prefilledSearchString: prefilled query 135 | */ 136 | export interface AdvancedSearchModalOptions { 137 | modalTitle?: string; 138 | preselectedAPIs?: string[]; 139 | prefilledSearchString?: string; 140 | } 141 | 142 | /** 143 | * Options for the id search modal. 144 | * - modalTitle: the title of the modal 145 | * - preselectedAPIs: a list of preselected APIs 146 | * - prefilledSearchString: prefilled query 147 | */ 148 | export interface IdSearchModalOptions { 149 | modalTitle?: string; 150 | preselectedAPI?: string; 151 | prefilledSearchString?: string; 152 | } 153 | 154 | /** 155 | * Options for the select modal. 156 | * - modalTitle: the title of the modal 157 | * - elements: the elements the user can select from 158 | * - multiSelect: whether to allow multiselect 159 | * - skipButton: whether to add a skip button to the modal 160 | */ 161 | export interface SelectModalOptions { 162 | modalTitle?: string; 163 | elements?: MediaTypeModel[]; 164 | multiSelect?: boolean; 165 | skipButton?: boolean; 166 | } 167 | 168 | /** 169 | * Options for the preview modal. 170 | * - modalTitle: the title of the modal 171 | * - elements: the elements to preview 172 | */ 173 | export interface PreviewModalOptions { 174 | modalTitle?: string; 175 | elements?: MediaTypeModel[]; 176 | } 177 | 178 | export const SEARCH_MODAL_DEFAULT_OPTIONS: SearchModalOptions = { 179 | modalTitle: 'Media DB Search', 180 | preselectedTypes: [], 181 | prefilledSearchString: '', 182 | }; 183 | 184 | export const ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS: AdvancedSearchModalOptions = { 185 | modalTitle: 'Media DB Advanced Search', 186 | preselectedAPIs: [], 187 | prefilledSearchString: '', 188 | }; 189 | 190 | export const ID_SEARCH_MODAL_DEFAULT_OPTIONS: IdSearchModalOptions = { 191 | modalTitle: 'Media DB Id Search', 192 | preselectedAPI: '', 193 | prefilledSearchString: '', 194 | }; 195 | 196 | export const SELECT_MODAL_OPTIONS_DEFAULT: SelectModalOptions = { 197 | modalTitle: 'Media DB Search Results', 198 | elements: [], 199 | multiSelect: true, 200 | skipButton: false, 201 | }; 202 | 203 | export const PREVIEW_MODAL_DEFAULT_OPTIONS: PreviewModalOptions = { 204 | modalTitle: 'Media DB Preview', 205 | elements: [], 206 | }; 207 | 208 | /** 209 | * A class providing multiple usefull functions for dealing with the plugins modals. 210 | */ 211 | export class ModalHelper { 212 | plugin: MediaDbPlugin; 213 | 214 | constructor(plugin: MediaDbPlugin) { 215 | this.plugin = plugin; 216 | } 217 | 218 | /** 219 | * Creates an {@link MediaDbSearchModal}, then sets callbacks and awaits them, 220 | * returning either the user input once submitted or nothing once closed. 221 | * The modal needs ot be manually closed by calling `close()` on the modal reference. 222 | * 223 | * @param searchModalOptions the options for the modal, see {@link SEARCH_MODAL_DEFAULT_OPTIONS} 224 | * @returns the user input or nothing and a reference to the modal. 225 | */ 226 | async createSearchModal(searchModalOptions: SearchModalOptions): Promise<{ searchModalResult: SearchModalResult; searchModal: MediaDbSearchModal }> { 227 | const modal = new MediaDbSearchModal(this.plugin, searchModalOptions); 228 | const res: SearchModalResult = await new Promise(resolve => { 229 | modal.setSubmitCallback(res => resolve({ code: ModalResultCode.SUCCESS, data: res })); 230 | modal.setCloseCallback(err => { 231 | if (err) { 232 | resolve({ code: ModalResultCode.ERROR, error: err }); 233 | } 234 | resolve({ code: ModalResultCode.CLOSE }); 235 | }); 236 | 237 | modal.open(); 238 | }); 239 | return { searchModalResult: res, searchModal: modal }; 240 | } 241 | 242 | /** 243 | * Opens an {@link MediaDbSearchModal} and awaits its result, 244 | * then executes the `submitCallback` returning the callbacks result and closing the modal. 245 | * 246 | * @param searchModalOptions the options for the modal, see {@link SEARCH_MODAL_DEFAULT_OPTIONS} 247 | * @param submitCallback the callback that gets executed after the modal has been submitted, but after it has been closed 248 | * @returns the user input or nothing and a reference to the modal. 249 | */ 250 | async openSearchModal( 251 | searchModalOptions: SearchModalOptions, 252 | submitCallback: (searchModalData: SearchModalData) => Promise, 253 | ): Promise { 254 | const { searchModalResult, searchModal } = await this.createSearchModal(searchModalOptions); 255 | console.debug(`MDB | searchModal closed with code ${searchModalResult.code}`); 256 | 257 | if (searchModalResult.code === ModalResultCode.ERROR) { 258 | // there was an error in the modal itself 259 | console.warn(searchModalResult.error); 260 | new Notice(searchModalResult.error.toString()); 261 | searchModal.close(); 262 | return undefined; 263 | } 264 | 265 | if (searchModalResult.code === ModalResultCode.CLOSE) { 266 | // modal is already being closed 267 | return undefined; 268 | } 269 | 270 | try { 271 | const callbackRes: MediaTypeModel[] = await submitCallback(searchModalResult.data); 272 | searchModal.close(); 273 | return callbackRes; 274 | } catch (e) { 275 | console.warn(e); 276 | new Notice(`${e}`); 277 | searchModal.close(); 278 | return undefined; 279 | } 280 | } 281 | 282 | /** 283 | * Creates an {@link MediaDbAdvancedSearchModal}, then sets callbacks and awaits them, 284 | * returning either the user input once submitted or nothing once closed. 285 | * The modal needs ot be manually closed by calling `close()` on the modal reference. 286 | * 287 | * @param advancedSearchModalOptions the options for the modal, see {@link ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS} 288 | * @returns the user input or nothing and a reference to the modal. 289 | */ 290 | async createAdvancedSearchModal( 291 | advancedSearchModalOptions: AdvancedSearchModalOptions, 292 | ): Promise<{ advancedSearchModalResult: AdvancedSearchModalResult; advancedSearchModal: MediaDbAdvancedSearchModal }> { 293 | const modal = new MediaDbAdvancedSearchModal(this.plugin, advancedSearchModalOptions); 294 | const res: AdvancedSearchModalResult = await new Promise(resolve => { 295 | modal.setSubmitCallback(res => resolve({ code: ModalResultCode.SUCCESS, data: res })); 296 | modal.setCloseCallback(err => { 297 | if (err) { 298 | resolve({ code: ModalResultCode.ERROR, error: err }); 299 | } 300 | resolve({ code: ModalResultCode.CLOSE }); 301 | }); 302 | 303 | modal.open(); 304 | }); 305 | return { advancedSearchModalResult: res, advancedSearchModal: modal }; 306 | } 307 | 308 | /** 309 | * Opens an {@link MediaDbAdvancedSearchModal} and awaits its result, 310 | * then executes the `submitCallback` returning the callbacks result and closing the modal. 311 | * 312 | * @param advancedSearchModalOptions the options for the modal, see {@link ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS} 313 | * @param submitCallback the callback that gets executed after the modal has been submitted, but after it has been closed 314 | * @returns the user input or nothing and a reference to the modal. 315 | */ 316 | async openAdvancedSearchModal( 317 | advancedSearchModalOptions: AdvancedSearchModalOptions, 318 | submitCallback: (advancedSearchModalData: AdvancedSearchModalData) => Promise, 319 | ): Promise { 320 | const { advancedSearchModalResult, advancedSearchModal } = await this.createAdvancedSearchModal(advancedSearchModalOptions); 321 | console.debug(`MDB | advencedSearchModal closed with code ${advancedSearchModalResult.code}`); 322 | 323 | if (advancedSearchModalResult.code === ModalResultCode.ERROR) { 324 | // there was an error in the modal itself 325 | console.warn(advancedSearchModalResult.error); 326 | new Notice(advancedSearchModalResult.error.toString()); 327 | advancedSearchModal.close(); 328 | return undefined; 329 | } 330 | 331 | if (advancedSearchModalResult.code === ModalResultCode.CLOSE) { 332 | // modal is already being closed 333 | return undefined; 334 | } 335 | 336 | try { 337 | const callbackRes: MediaTypeModel[] = await submitCallback(advancedSearchModalResult.data); 338 | advancedSearchModal.close(); 339 | return callbackRes; 340 | } catch (e) { 341 | console.warn(e); 342 | new Notice(`${e}`); 343 | advancedSearchModal.close(); 344 | return undefined; 345 | } 346 | } 347 | 348 | /** 349 | * Creates an {@link MediaDbIdSearchModal}, then sets callbacks and awaits them, 350 | * returning either the user input once submitted or nothing once closed. 351 | * The modal needs ot be manually closed by calling `close()` on the modal reference. 352 | * 353 | * @param idSearchModalOptions the options for the modal, see {@link ID_SEARCH_MODAL_DEFAULT_OPTIONS} 354 | * @returns the user input or nothing and a reference to the modal. 355 | */ 356 | async createIdSearchModal(idSearchModalOptions: IdSearchModalOptions): Promise<{ idSearchModalResult: IdSearchModalResult; idSearchModal: MediaDbIdSearchModal }> { 357 | const modal = new MediaDbIdSearchModal(this.plugin, idSearchModalOptions); 358 | const res: IdSearchModalResult = await new Promise(resolve => { 359 | modal.setSubmitCallback(res => resolve({ code: ModalResultCode.SUCCESS, data: res })); 360 | modal.setCloseCallback(err => { 361 | if (err) { 362 | resolve({ code: ModalResultCode.ERROR, error: err }); 363 | } 364 | resolve({ code: ModalResultCode.CLOSE }); 365 | }); 366 | 367 | modal.open(); 368 | }); 369 | return { idSearchModalResult: res, idSearchModal: modal }; 370 | } 371 | 372 | /** 373 | * Opens an {@link MediaDbIdSearchModal} and awaits its result, 374 | * then executes the `submitCallback` returning the callbacks result and closing the modal. 375 | * 376 | * @param idSearchModalOptions the options for the modal, see {@link ID_SEARCH_MODAL_DEFAULT_OPTIONS} 377 | * @param submitCallback the callback that gets executed after the modal has been submitted, but after it has been closed 378 | * @returns the user input or nothing and a reference to the modal. 379 | */ 380 | async openIdSearchModal( 381 | idSearchModalOptions: IdSearchModalOptions, 382 | submitCallback: (idSearchModalData: IdSearchModalData) => Promise, 383 | ): Promise { 384 | const { idSearchModalResult, idSearchModal } = await this.createIdSearchModal(idSearchModalOptions); 385 | console.debug(`MDB | idSearchModal closed with code ${idSearchModalResult.code}`); 386 | 387 | if (idSearchModalResult.code === ModalResultCode.ERROR) { 388 | // there was an error in the modal itself 389 | console.warn(idSearchModalResult.error); 390 | new Notice(idSearchModalResult.error.toString()); 391 | idSearchModal.close(); 392 | return undefined; 393 | } 394 | 395 | if (idSearchModalResult.code === ModalResultCode.CLOSE) { 396 | // modal is already being closed 397 | return undefined; 398 | } 399 | 400 | try { 401 | const callbackRes = await submitCallback(idSearchModalResult.data); 402 | idSearchModal.close(); 403 | return callbackRes; 404 | } catch (e) { 405 | console.warn(e); 406 | new Notice(`${e}`); 407 | idSearchModal.close(); 408 | return undefined; 409 | } 410 | } 411 | 412 | /** 413 | * Creates an {@link MediaDbSearchResultModal}, then sets callbacks and awaits them, 414 | * returning either the user input once submitted or nothing once closed. 415 | * The modal needs ot be manually closed by calling `close()` on the modal reference. 416 | * 417 | * @param selectModalOptions the options for the modal, see {@link SELECT_MODAL_OPTIONS_DEFAULT} 418 | * @returns the user input or nothing and a reference to the modal. 419 | */ 420 | async createSelectModal(selectModalOptions: SelectModalOptions): Promise<{ selectModalResult: SelectModalResult; selectModal: MediaDbSearchResultModal }> { 421 | const modal = new MediaDbSearchResultModal(this.plugin, selectModalOptions); 422 | const res: SelectModalResult = await new Promise(resolve => { 423 | modal.setSubmitCallback(res => resolve({ code: ModalResultCode.SUCCESS, data: res })); 424 | modal.setSkipCallback(() => resolve({ code: ModalResultCode.SKIP })); 425 | modal.setCloseCallback(err => { 426 | if (err) { 427 | resolve({ code: ModalResultCode.ERROR, error: err }); 428 | } 429 | resolve({ code: ModalResultCode.CLOSE }); 430 | }); 431 | 432 | modal.open(); 433 | }); 434 | return { selectModalResult: res, selectModal: modal }; 435 | } 436 | 437 | /** 438 | * Opens an {@link MediaDbSearchResultModal} and awaits its result, 439 | * then executes the `submitCallback` returning the callbacks result and closing the modal. 440 | * 441 | * @param selectModalOptions the options for the modal, see {@link SELECT_MODAL_OPTIONS_DEFAULT} 442 | * @param submitCallback the callback that gets executed after the modal has been submitted, but before it has been closed 443 | * @returns the user input or nothing and a reference to the modal. 444 | */ 445 | async openSelectModal( 446 | selectModalOptions: SelectModalOptions, 447 | submitCallback: (selectModalData: SelectModalData) => Promise, 448 | ): Promise { 449 | const { selectModalResult, selectModal } = await this.createSelectModal(selectModalOptions); 450 | console.debug(`MDB | selectModal closed with code ${selectModalResult.code}`); 451 | 452 | if (selectModalResult.code === ModalResultCode.ERROR) { 453 | // there was an error in the modal itself 454 | console.warn(selectModalResult.error); 455 | new Notice(selectModalResult.error.toString()); 456 | selectModal.close(); 457 | return undefined; 458 | } 459 | 460 | if (selectModalResult.code === ModalResultCode.CLOSE) { 461 | // modal is already being closed 462 | return undefined; 463 | } 464 | 465 | if (selectModalResult.code === ModalResultCode.SKIP) { 466 | // selection was skipped 467 | return undefined; 468 | } 469 | 470 | try { 471 | const callbackRes: MediaTypeModel[] = await submitCallback(selectModalResult.data); 472 | selectModal.close(); 473 | return callbackRes; 474 | } catch (e) { 475 | console.warn(e); 476 | new Notice(`${e}`); 477 | selectModal.close(); 478 | return; 479 | } 480 | } 481 | 482 | async createPreviewModal(previewModalOptions: PreviewModalOptions): Promise<{ previewModalResult: PreviewModalResult; previewModal: MediaDbPreviewModal }> { 483 | //todo: handle attachFile for existing files 484 | const modal = new MediaDbPreviewModal(this.plugin, previewModalOptions); 485 | const res: PreviewModalResult = await new Promise(resolve => { 486 | modal.setSubmitCallback(res => resolve({ code: ModalResultCode.SUCCESS, data: res })); 487 | modal.setCloseCallback(err => { 488 | if (err) { 489 | resolve({ code: ModalResultCode.ERROR, error: err }); 490 | } 491 | resolve({ code: ModalResultCode.CLOSE }); 492 | }); 493 | 494 | modal.open(); 495 | }); 496 | return { previewModalResult: res, previewModal: modal }; 497 | } 498 | 499 | async openPreviewModal(previewModalOptions: PreviewModalOptions, submitCallback: (previewModalData: PreviewModalData) => Promise): Promise { 500 | const { previewModalResult, previewModal } = await this.createPreviewModal(previewModalOptions); 501 | console.debug(`MDB | previewModal closed with code ${previewModalResult.code}`); 502 | 503 | if (previewModalResult.code === ModalResultCode.ERROR) { 504 | // there was an error in the modal itself 505 | console.warn(previewModalResult.error); 506 | new Notice(previewModalResult.error.toString()); 507 | previewModal.close(); 508 | return false; 509 | } 510 | 511 | if (previewModalResult.code === ModalResultCode.CLOSE) { 512 | // modal is already being closed 513 | return false; 514 | } 515 | 516 | try { 517 | const callbackRes: boolean = await submitCallback(previewModalResult.data); 518 | previewModal.close(); 519 | return callbackRes; 520 | } catch (e) { 521 | console.warn(e); 522 | new Notice(`${e}`); 523 | previewModal.close(); 524 | return true; 525 | } 526 | } 527 | } 528 | -------------------------------------------------------------------------------- /src/utils/Utils.ts: -------------------------------------------------------------------------------- 1 | import type { TFile, TFolder, App } from 'obsidian'; 2 | import type { MediaTypeModel } from '../models/MediaTypeModel'; 3 | 4 | export const pluginName: string = 'obsidian-media-db-plugin'; 5 | export const contactEmail: string = 'm.projects.code@gmail.com'; 6 | export const mediaDbTag: string = 'mediaDB'; 7 | export const mediaDbVersion: string = '0.5.2'; 8 | export const debug: boolean = true; 9 | 10 | export function wrapAround(value: number, size: number): number { 11 | if (size <= 0) { 12 | throw Error('size may not be zero or negative'); 13 | } 14 | return mod(value, size); 15 | } 16 | 17 | export function containsOnlyLettersAndUnderscores(str: string): boolean { 18 | return /^[\p{Letter}\p{M}_]+$/u.test(str); 19 | } 20 | 21 | export function replaceIllegalFileNameCharactersInString(string: string): string { 22 | return string.replace(/[\\,#%&{}/*<>$"@.?]*/g, '').replace(/:+/g, ' -'); 23 | } 24 | 25 | export function replaceTags(template: string, mediaTypeModel: MediaTypeModel, ignoreUndefined: boolean = false): string { 26 | return template.replace(new RegExp('{{.*?}}', 'g'), (match: string) => replaceTag(match, mediaTypeModel, ignoreUndefined)); 27 | } 28 | 29 | function replaceTag(match: string, mediaTypeModel: MediaTypeModel, ignoreUndefined: boolean): string { 30 | let tag = match; 31 | tag = tag.substring(2); 32 | tag = tag.substring(0, tag.length - 2); 33 | tag = tag.trim(); 34 | 35 | const parts = tag.split(':'); 36 | if (parts.length === 1) { 37 | const path = parts[0].split('.'); 38 | 39 | const obj = traverseMetaData(path, mediaTypeModel); 40 | 41 | if (obj === undefined) { 42 | return ignoreUndefined ? '' : '{{ INVALID TEMPLATE TAG - object undefined }}'; 43 | } 44 | 45 | return obj; 46 | } else if (parts.length === 2) { 47 | const operator = parts[0]; 48 | 49 | const path = parts[1].split('.'); 50 | 51 | const obj = traverseMetaData(path, mediaTypeModel); 52 | 53 | if (obj === undefined) { 54 | return ignoreUndefined ? '' : '{{ INVALID TEMPLATE TAG - object undefined }}'; 55 | } 56 | 57 | if (operator === 'LIST') { 58 | if (!Array.isArray(obj)) { 59 | return '{{ INVALID TEMPLATE TAG - operator LIST is only applicable on an array }}'; 60 | } 61 | return obj.map((e: any) => `- ${e}`).join('\n'); 62 | } else if (operator === 'ENUM') { 63 | if (!Array.isArray(obj)) { 64 | return '{{ INVALID TEMPLATE TAG - operator ENUM is only applicable on an array }}'; 65 | } 66 | return obj.join(', '); 67 | } else if (operator === 'FIRST') { 68 | if (!Array.isArray(obj)) { 69 | return '{{ INVALID TEMPLATE TAG - operator FIRST is only applicable on an array }}'; 70 | } 71 | return obj[0]; 72 | } else if (operator === 'LAST') { 73 | if (!Array.isArray(obj)) { 74 | return '{{ INVALID TEMPLATE TAG - operator LAST is only applicable on an array }}'; 75 | } 76 | return obj[obj.length - 1]; 77 | } 78 | 79 | return `{{ INVALID TEMPLATE TAG - unknown operator ${operator} }}`; 80 | } 81 | 82 | return '{{ INVALID TEMPLATE TAG }}'; 83 | } 84 | 85 | function traverseMetaData(path: string[], mediaTypeModel: MediaTypeModel): any { 86 | let o: any = mediaTypeModel; 87 | 88 | for (const part of path) { 89 | if (o !== undefined) { 90 | o = o[part]; 91 | } 92 | } 93 | 94 | return o; 95 | } 96 | 97 | export function markdownTable(content: string[][]): string { 98 | const rows = content.length; 99 | if (rows === 0) { 100 | return ''; 101 | } 102 | 103 | const columns = content[0].length; 104 | if (columns === 0) { 105 | return ''; 106 | } 107 | for (const row of content) { 108 | if (row.length !== columns) { 109 | return ''; 110 | } 111 | } 112 | 113 | const longestStringInColumns: number[] = []; 114 | 115 | for (let i = 0; i < columns; i++) { 116 | let longestStringInColumn = 0; 117 | for (const row of content) { 118 | if (row[i].length > longestStringInColumn) { 119 | longestStringInColumn = row[i].length; 120 | } 121 | } 122 | 123 | longestStringInColumns.push(longestStringInColumn); 124 | } 125 | 126 | let table = ''; 127 | 128 | for (let i = 0; i < rows; i++) { 129 | table += '|'; 130 | for (let j = 0; j < columns; j++) { 131 | let element = content[i][j]; 132 | element += ' '.repeat(longestStringInColumns[j] - element.length); 133 | table += ' ' + element + ' |'; 134 | } 135 | table += '\n'; 136 | if (i === 0) { 137 | table += '|'; 138 | for (let j = 0; j < columns; j++) { 139 | table += ' ' + '-'.repeat(longestStringInColumns[j]) + ' |'; 140 | } 141 | table += '\n'; 142 | } 143 | } 144 | 145 | return table; 146 | } 147 | 148 | export function fragWithHTML(html: string): DocumentFragment { 149 | return createFragment(frag => (frag.createDiv().innerHTML = html)); 150 | } 151 | 152 | export function dateToString(date: Date): string { 153 | return `${date.getMonth() + 1}-${date.getDate()}-${date.getFullYear()}`; 154 | } 155 | 156 | export function timeToString(time: Date): string { 157 | return `${time.getHours()}-${time.getMinutes()}-${time.getSeconds()}`; 158 | } 159 | 160 | export function dateTimeToString(dateTime: Date): string { 161 | return `${dateToString(dateTime)} ${timeToString(dateTime)}`; 162 | } 163 | 164 | // js can't even implement modulo correctly... 165 | export function mod(n: number, m: number): number { 166 | return ((n % m) + m) % m; 167 | } 168 | 169 | export function capitalizeFirstLetter(string: string): string { 170 | return string.charAt(0).toUpperCase() + string.slice(1); 171 | } 172 | 173 | export class PropertyMappingValidationError extends Error { 174 | constructor(message: string) { 175 | super(message); 176 | } 177 | } 178 | 179 | export class PropertyMappingNameConflictError extends Error { 180 | constructor(message: string) { 181 | super(message); 182 | } 183 | } 184 | 185 | /** 186 | * - attachTemplate: whether to attach the template (DEFAULT: false) 187 | * - attachFie: a file to attach (DEFAULT: undefined) 188 | * - openNote: whether to open the note after creation (DEFAULT: false) 189 | * - folder: folder to put the note in 190 | */ 191 | export interface CreateNoteOptions { 192 | attachTemplate?: boolean; 193 | attachFile?: TFile; 194 | openNote?: boolean; 195 | folder?: TFolder; 196 | } 197 | 198 | export function migrateObject(object: T, oldData: any, defaultData: T): void { 199 | for (const key in object) { 200 | object[key] = oldData.hasOwnProperty(key) ? oldData[key] : defaultData[key]; 201 | } 202 | } 203 | 204 | export function unCamelCase(str: string): string { 205 | return ( 206 | str 207 | // insert a space between lower & upper 208 | .replace(/([a-z])([A-Z])/g, '$1 $2') 209 | // space before last upper in a sequence followed by lower 210 | .replace(/\b([A-Z]+)([A-Z])([a-z])/, '$1 $2$3') 211 | // uppercase the first character 212 | .replace(/^./, function (str) { 213 | return str.toUpperCase(); 214 | }) 215 | ); 216 | } 217 | 218 | export function hasTemplaterPlugin(app: App): boolean { 219 | const templater = (app as any).plugins.plugins['templater-obsidian']; 220 | 221 | return !!templater; 222 | } 223 | 224 | // Copied from https://github.com/anpigon/obsidian-book-search-plugin 225 | // Licensed under the MIT license. Copyright (c) 2020 Jake Runzer 226 | export async function useTemplaterPluginInFile(app: App, file: TFile): Promise { 227 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 228 | const templater = (app as any).plugins.plugins['templater-obsidian']; 229 | if (templater && !templater?.settings.trigger_on_file_creation) { 230 | await templater.templater.overwrite_file_commands(file); 231 | } 232 | } 233 | 234 | export type ModelToData = { 235 | [K in keyof T as T[K] extends Function ? never : K]?: T[K]; 236 | }; 237 | 238 | // Checks if a given URL points to an existing image (status 200), or returns false for 404/other errors. 239 | 240 | export async function imageUrlExists(url: string): Promise { 241 | try { 242 | // @ts-ignore 243 | const response = await requestUrl({ 244 | url, 245 | method: 'HEAD', 246 | throw: false, 247 | }); 248 | return response.status === 200; 249 | } catch { 250 | return false; 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .media-db-plugin-list-wrapper { 2 | display: flex; 3 | align-content: center; 4 | margin-bottom: 5px; 5 | margin-top: 5px; 6 | } 7 | 8 | .media-db-plugin-list-toggle { 9 | } 10 | 11 | .media-db-plugin-list-text-wrapper { 12 | flex: 1; 13 | } 14 | 15 | .media-db-plugin-list-text { 16 | display: block; 17 | } 18 | 19 | small.media-db-plugin-list-text { 20 | color: var(--text-muted); 21 | } 22 | 23 | .media-db-plugin-select-modal { 24 | display: contents; 25 | } 26 | 27 | .media-db-plugin-select-wrapper { 28 | display: flex; 29 | flex-direction: column; 30 | margin: 5px; 31 | overflow-y: auto; 32 | } 33 | 34 | .media-db-plugin-select-element { 35 | cursor: pointer; 36 | border-left: 5px solid transparent; 37 | padding: 5px; 38 | margin: 5px 0 5px 0; 39 | border-radius: 5px; 40 | white-space: pre-wrap; 41 | font-size: 16px; 42 | } 43 | 44 | .media-db-plugin-select-element-selected { 45 | border-left: 5px solid var(--interactive-accent) !important; 46 | background: var(--background-secondary-alt); 47 | } 48 | 49 | .media-db-plugin-select-element-hover { 50 | background: var(--background-secondary-alt); 51 | } 52 | 53 | .media-db-plugin-preview-modal { 54 | display: contents; 55 | } 56 | 57 | .media-db-plugin-preview-wrapper { 58 | display: flex; 59 | flex-direction: column; 60 | overflow-y: auto; 61 | } 62 | 63 | .media-db-plugin-spacer { 64 | margin-bottom: 10px; 65 | } 66 | 67 | /* region property mappings */ 68 | .media-db-plugin-property-mappings-model-container { 69 | border: 1px solid var(--background-modifier-border); 70 | border-radius: 5px; 71 | padding: 10px; 72 | width: 100%; 73 | } 74 | 75 | .media-db-plugin-property-mappings-container { 76 | margin: 10px 0; 77 | display: flex; 78 | flex-direction: column; 79 | gap: 5px; 80 | } 81 | 82 | .media-db-plugin-property-mapping-element { 83 | display: flex; 84 | flex-direction: row; 85 | gap: 10px; 86 | } 87 | 88 | .media-db-plugin-property-mapping-element-property-name-wrapper { 89 | min-width: 160px; 90 | background: var(--background-modifier-form-field); 91 | padding: 2px 5px; 92 | border-radius: 5px; 93 | 94 | display: flex; 95 | align-items: center; 96 | } 97 | 98 | .media-db-plugin-property-mapping-element-property-name { 99 | margin: 0; 100 | } 101 | 102 | .media-db-plugin-property-mappings-save-button { 103 | margin: 0; 104 | } 105 | 106 | .media-db-plugin-property-mapping-to { 107 | display: flex; 108 | align-items: center; 109 | } 110 | 111 | .media-db-plugin-property-mapping-validation { 112 | color: var(--text-error); 113 | margin-bottom: 5px; 114 | } 115 | 116 | .media-db-plugin-button:focus { 117 | /*outline: 1px solid white;*/ 118 | } 119 | 120 | .media-db-plugin-preview { 121 | border-radius: var(--modal-radius); 122 | border: var(--modal-border-width) solid var(--modal-border-color); 123 | padding: var(--size-4-4); 124 | } 125 | 126 | /* endregion */ 127 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "allowJs": true, 7 | "checkJs": true, 8 | "noImplicitAny": true, 9 | "strict": true, 10 | "strictNullChecks": true, 11 | "noImplicitReturns": true, 12 | "moduleResolution": "node", 13 | "importHelpers": true, 14 | "isolatedModules": true, 15 | "skipLibCheck": true, 16 | "verbatimModuleSyntax": true, 17 | "resolveJsonModule": true, 18 | "moduleDetection": "force", 19 | "sourceMap": true, 20 | "lib": ["DOM", "ESNext"], 21 | "allowSyntheticDefaultImports": true 22 | }, 23 | "include": ["src/**/*.ts", "tests/**/*.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync('manifest.json', 'utf8')); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t')); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync('versions.json', 'utf8')); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.7": "0.14.0", 3 | "0.7.0": "1.5.0", 4 | "0.7.1": "1.5.0", 5 | "0.7.2": "1.5.0", 6 | "0.8.0": "1.5.0" 7 | } 8 | --------------------------------------------------------------------------------