├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── pre-release.yml │ ├── release.yml │ └── sonar-analysis.yml ├── .gitignore ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── assets ├── mobile-search.png └── preview.gif ├── esbuild.config.mjs ├── jest.config.js ├── manifest.json ├── package-lock.json ├── package.json ├── resources └── save-data-schema.json ├── sonar-project.properties ├── src ├── libraries │ ├── comparison │ │ ├── compareCharacterMatches.ts │ │ ├── compareCharacters.ts │ │ ├── compareCodepoints.ts │ │ ├── compareDates.ts │ │ ├── compareFavoriteCharacters.ts │ │ ├── compareFavoriteInfo.ts │ │ ├── compareNullable.ts │ │ ├── compareNumbers.ts │ │ ├── compareSearchMatchScores.ts │ │ ├── compareSearchMatches.ts │ │ ├── compareUsageInfo.ts │ │ ├── compareUsedCharacters.ts │ │ ├── fillNullCharacterMatchScores.ts │ │ └── fillNullSearchMatchScores.ts │ ├── data │ │ ├── characterCategory.ts │ │ ├── characterCategoryGroup.ts │ │ ├── unicodeCharacterCategories.ts │ │ ├── unicodePlaneNumber.ts │ │ └── unicodePlanes.ts │ ├── helpers │ │ ├── asHexadecimal.ts │ │ ├── averageUseCount.ts │ │ ├── codePointIn.ts │ │ ├── getRandomItem.ts │ │ ├── intervalWithin.ts │ │ ├── intervalsEqual.ts │ │ ├── isFavoriteCharacter.ts │ │ ├── isTypeSaveData.ts │ │ ├── isUsedCharacter.ts │ │ ├── matchedNameOrCodepoint.ts │ │ ├── mergeIntervals.ts │ │ ├── mostRecentUses.ts │ │ ├── parseUsageInfo.ts │ │ ├── serializeFavoriteInfo.ts │ │ ├── serializeUsageInfo.ts │ │ ├── toHexadecimal.ts │ │ ├── toNullMatch.ts │ │ └── toSearchQueryMatch.ts │ ├── order │ │ ├── inverse.ts │ │ └── order.ts │ └── types │ │ ├── codepoint │ │ ├── character.ts │ │ ├── codepointInterval.ts │ │ ├── extension.ts │ │ └── unicode.ts │ │ ├── maybe.ts │ │ ├── persistCache.ts │ │ ├── readCache.ts │ │ ├── savedata │ │ ├── dataFragment.ts │ │ ├── favoriteInfo.ts │ │ ├── favoritesFragment.ts │ │ ├── filterFragment.ts │ │ ├── initialSaveData.ts │ │ ├── metaFragment.ts │ │ ├── saveData.ts │ │ ├── unicodeFilter.ts │ │ ├── unicodeFragment.ts │ │ ├── usageFragment.ts │ │ ├── usageInfo.ts │ │ └── version.ts │ │ ├── unicode │ │ ├── unicodeBlock.ts │ │ ├── unicodeGeneralCategory.ts │ │ ├── unicodeGeneralCategoryGroup.ts │ │ └── unicodePlane.ts │ │ └── usageDisplayStatistics.ts └── unicode-search │ ├── components │ ├── characterSearch.ts │ ├── characterSearchAttributes.ts │ ├── fuzzySearchModal.ts │ ├── insertCharacterModal.ts │ ├── pickCharacterModal.ts │ ├── settingTab.ts │ └── visualElements.ts │ ├── errors │ └── unicodeSearchError.ts │ ├── main.ts │ ├── service │ ├── characterDownloader.ts │ ├── characterService.ts │ ├── codePointStore.ts │ ├── codepointFavoritesStorage.ts │ ├── codepointStorage.ts │ ├── codepointUsageStorage.ts │ ├── commander.ts │ ├── dataFragmentManager.ts │ ├── dataManager.ts │ ├── favoritesDataManager.ts │ ├── favoritesStore.ts │ ├── filterDataManager.ts │ ├── filterStorage.ts │ ├── filterStore.ts │ ├── metaDataManager.ts │ ├── metaStorage.ts │ ├── rootDataManager.ts │ ├── rootDataStore.ts │ ├── rootPluginDataStorage.ts │ ├── ucdUserFilterDownloader.ts │ ├── unicodeDataManager.ts │ ├── usageDataManager.ts │ ├── usageStore.ts │ └── userCharacterService.ts │ └── styles.scss ├── tests └── libraries │ ├── comparison │ ├── compareCodepoints.spec.ts │ ├── compareDates.test.ts │ ├── compareFavoriteCharacters.spec.ts │ ├── compareFavoriteInfo.spec.ts │ ├── compareNullable.spec.ts │ ├── compareNumbers.spec.ts │ ├── compareUsageInfo.spec.ts │ └── compareUsedCharacters.spec.ts │ └── helpers │ ├── averageUseCount.spec.ts │ ├── codePointIn.spec.ts │ ├── hexadecimal.characters.spec.ts │ ├── intervalWithin.spec.ts │ ├── intervalsEqual.spec.ts │ └── mergeIntervals.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{yml,yaml,json}] 12 | indent_size = 2 13 | 14 | [package-lock.json] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Normalize line endings to LF. 2 | * text eol=lf 3 | # Ensure the following are treated as binary. 4 | *.gif filter=lfs diff=lfs merge=lfs -text 5 | *.jar binary 6 | *.jpeg binary 7 | *.jpg binary 8 | *.png filter=lfs diff=lfs merge=lfs -text 9 | *.vsd binary 10 | -------------------------------------------------------------------------------- /.github/workflows/pre-release.yml: -------------------------------------------------------------------------------- 1 | name: Pre-release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "release/[0-9]+.[0-9]+.[0-9]+" 7 | 8 | env: 9 | PLUGIN_NAME: "unicode-search" 10 | BUILD_DIR: "./dist" 11 | RELEASE_VERSION: ${{ github.ref_name }} 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: "18" 22 | 23 | - name: NPM Install and build 24 | run: | 25 | npm clean-install 26 | npm run build --if-present 27 | 28 | - name: ZIP build output 29 | working-directory: ${{ env.BUILD_DIR }} 30 | run: | 31 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | 8 | env: 9 | PLUGIN_NAME: "unicode-search" 10 | BUILD_DIR: "./dist" 11 | RELEASE_VERSION: ${{ github.ref_name }} 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: "18" 22 | 23 | - name: NPM Install and build 24 | run: | 25 | npm clean-install 26 | npm run build --if-present 27 | 28 | - name: ZIP build output 29 | working-directory: ${{ env.BUILD_DIR }} 30 | run: | 31 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 32 | 33 | - name: Create Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | with: 39 | tag_name: ${{ env.RELEASE_VERSION }} 40 | release_name: ${{ env.RELEASE_VERSION }} 41 | draft: false 42 | prerelease: false 43 | 44 | - name: Upload manifest 45 | uses: actions/upload-release-asset@v1 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | upload_url: ${{ steps.create_release.outputs.upload_url }} 50 | asset_path: ./${{ env.BUILD_DIR }}/${{ env.PLUGIN_NAME }}/manifest.json 51 | asset_name: manifest.json 52 | asset_content_type: application/json 53 | 54 | - name: Upload script 55 | uses: actions/upload-release-asset@v1 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | with: 59 | upload_url: ${{ steps.create_release.outputs.upload_url }} 60 | asset_path: ./${{ env.BUILD_DIR }}/${{ env.PLUGIN_NAME }}/main.js 61 | asset_name: main.js 62 | asset_content_type: text/javascript 63 | 64 | - name: Upload stylesheet 65 | uses: actions/upload-release-asset@v1 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | with: 69 | upload_url: ${{ steps.create_release.outputs.upload_url }} 70 | asset_path: ./${{ env.BUILD_DIR }}/${{ env.PLUGIN_NAME }}/styles.css 71 | asset_name: styles.css 72 | asset_content_type: text/css 73 | 74 | - name: Upload archive 75 | uses: actions/upload-release-asset@v1 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | with: 79 | upload_url: ${{ steps.create_release.outputs.upload_url }} 80 | asset_path: ./${{ env.BUILD_DIR }}/${{ env.PLUGIN_NAME }}.zip 81 | asset_name: ${{ env.PLUGIN_NAME }}-${{ env.RELEASE_VERSION }}.zip 82 | asset_content_type: application/zip 83 | -------------------------------------------------------------------------------- /.github/workflows/sonar-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Sonar Analysis 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | 8 | sonarcloud: 9 | name: SonarCloud 10 | runs-on: ubuntu-latest 11 | steps: 12 | 13 | - uses: actions/checkout@v3 14 | with: 15 | # Shallow clones should be disabled for a better relevancy of analysis 16 | fetch-depth: 0 17 | 18 | - name: Use node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: "18" 22 | 23 | - name: NPM Install 24 | run: | 25 | npm clean-install 26 | 27 | - name: Test with coverage 28 | run: | 29 | npx jest --coverage 30 | 31 | - name: SonarCloud Scan 32 | uses: SonarSource/sonarqube-scan-action@master 33 | env: 34 | # Needed to get PR information, if any 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ Idea 2 | /.idea/ 3 | 4 | # Visual Studio Code 5 | /.vscode/ 6 | 7 | # NPM 8 | /node_modules/ 9 | 10 | # Build output 11 | /dist/ 12 | 13 | # Exclude sourcemaps 14 | *.map 15 | 16 | # Obsidian 17 | /data.json 18 | 19 | # Exclude macOS Finder (System Explorer) View States 20 | .DS_Store 21 | 22 | /.ignore/ 23 | 24 | /coverage/ 25 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Unicode Search: Development 2 | 3 | These are my notes on the development of the plugin. 4 | 5 | ## Release: How To 6 | 7 | 1. From the `develop` branch, create a release branch `release/X.Y.Z` 8 | 2. Go to [package.json](./package.json) and overwrite `X.Y.Z-NEXT` with new version throughout the whole project (double check if save data version needs to be updated) 9 | 3. Update the [package-lock](./package-lock.json) file: `npm install` 10 | 4. Export save data schema: `npm run export-schema` 11 | 5. Run tests: `npm run test` 12 | 6. Run build and test out the app: `npm run build` 13 | 7. Commit as "Version `X.Y.Z`" 14 | 8. Push, and fix if build fails, otherwise merge to the `main` branch 15 | 9. Create a tag with the label `X.Y.Z` 16 | 10. GitHub Actions will create a [release](https://github.com/BambusControl/obsidian-unicode-search/releases) 17 | 11. Add release notes to the release 18 | 12. Fast-forward the `develop` branch 19 | 13. Go to [package.json](./package.json) and overwrite `X.Y.Z` with _next_ version `X.Y.Z-NEXT` throughout the whole project 20 | 14. Update the [package-lock](./package-lock.json) file: `npm install` 21 | 15. Commit as "Set version as `X.Y.Z-NEXT`" to the `develop` branch 22 | 23 | ## Development Diary 24 | 25 | ### 22. April 2025 26 | 27 | The save data rework is kind-of done. 28 | Still, there is overlap between fetching and initializing data which I want to look at later. 29 | 30 | ### 23. December 2024 31 | 32 | Before releasing the new feature, I have to fix the user save data implementation. 33 | Now it deletes their data when a new version of the plugin is loaded. 34 | So, it would forget the favorite characters after an update. 35 | 36 | ### 19. December 2024 37 | 38 | This small plugin became a bit too elaborately written. 39 | I added the "_favorite characters_," and I'm almost done with it. 40 | It works alright, but adding new settings is tedious, so I'm thinking of adding Svelte as the next step (to make the UI modifications easier.) 41 | 42 | Also, I want to simplify the code after, because I believe the codebase exploded for no good reason. 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Mojmír Majer 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Unicode Search 2 | 3 | ![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?label=downloads&query=%24%5B%22unicode-search%22%5D%5B%22downloads%22%5D&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json&logo=obsidian&color=8b6cef&logoColor=8b6cef&labelColor=f1f2f3&logoWidth=20&style=for-the-badge) 4 | 5 | > See [what's new](https://github.com/BambusControl/obsidian-unicode-search/releases)! 6 | 7 | Search the [Unicode Character Database](https://www.unicode.org/ucd/) index 8 | and insert any character into your editor. 9 | Mobile is also supported! 10 | 11 | > *This is a plugin for [Obsidian: unicode-search](https://obsidian.md/plugins?id=unicode-search)*. 12 | 13 |

Preview

18 | 19 | ## Usage 20 | 21 | The plugin adds a command for searching unicode characters. 22 | Make sure to add a hotkey, like Ctrl + Shift + O for the command in the settings for Obsidian. 23 | 24 | Just describe the character you're searching for 25 | and press to insert it into the editor. 26 | You can also search by Unicode codepoints! 27 | 28 |

Search '269' mobile preview

33 | 34 | ## Features 35 | 36 | - **Fuzzy Search**: Find characters by name, codepoint, or keywords. 37 | - **Direct Insert**: Insert characters directly into your editor. 38 | - **Favorites & Hotkeys**: Mark frequently used characters as favorites and assign custom hotkeys for quick insertion. 39 | - **Persistent Filters**: Configure and save filters for planes, categories, and custom sets to refine your searches. 40 | - **Usage Statistics**: Quickly access frequently used symbols. 41 | - **Configurable**: All settings are managed within Obsidian's settings pane. 42 | 43 | ## Using the Plugin 44 | 45 | To begin using the plugin, launch the search by executing the **Search Unicode characters** command. For more convenient access, consider assigning a hotkey: 46 | 47 | - Navigate to **Settings → Hotkeys**. 48 | - Find **Search Unicode characters** and assign your preferred hotkey (e.g., `Ctrl+Shift+O`). 49 | 50 | ### Searching for Characters 51 | 52 | Once the search modal is open, you can find characters by typing your query. This can be a descriptive term like `arrow` or `heart`, or a specific codepoint such as `269`. 53 | 54 | - Use the `↑` and `↓` arrow keys to navigate through the search results. 55 | - Press `Enter` to insert the selected character directly into your document. 56 | 57 | ### Managing Favorites 58 | 59 | You can manage your favorite characters from the search modal by adding them in the plugin settings. 60 | 61 | - View and manage all your favorites in **Settings → Unicode Search → Favorites**. 62 | - Assign a hotkey to any favorite character via the plugin's settings tab. This creates a new command, "Insert '\'", which can then have a hotkey assigned under **Settings → Hotkeys → Insert '\'**. 63 | 64 | ### Filtering Characters 65 | 66 | Character filters help you refine which characters appear in your search results. These are configured in **Settings → Unicode Search**. 67 | 68 | - Toggle filters for various Unicode planes and categories. 69 | - Define custom filters to tailor the search to your needs. 70 | - Be aware that a default set of filters is active. If you're unable to find a specific character, it might be excluded by the current filter settings. 71 | - Search for your character to find out it's plane/block/category, if it's missing here: 72 | -------------------------------------------------------------------------------- /assets/mobile-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/4d434fc42c5f18dd5d123ab361ca55e1515c9d75/assets/mobile-search.png -------------------------------------------------------------------------------- /assets/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/4d434fc42c5f18dd5d123ab361ca55e1515c9d75/assets/preview.gif -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import {sassPlugin} from "esbuild-sass-plugin"; 5 | import * as fs from "fs/promises"; 6 | 7 | const banner = 8 | "/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD see https://github.com/BambusControl/obsidian-unicode-search for the source */"; 9 | 10 | const prodBuild = (process.argv[2] === "production"); 11 | 12 | const sourceDir = "./src/unicode-search"; 13 | const outputDir = "./dist/unicode-search"; 14 | 15 | /* Copy the manifest for working*/ 16 | await fs.mkdir(outputDir, {recursive: true}); 17 | await fs.copyFile("manifest.json", `${outputDir}/manifest.json`); 18 | 19 | const buildOptions = { 20 | banner: { 21 | js: banner, 22 | }, 23 | entryPoints: [ 24 | `${sourceDir}/main.ts`, 25 | `${sourceDir}/styles.scss`, 26 | ], 27 | entryNames: "[name]", 28 | outdir: outputDir, 29 | bundle: true, 30 | external: [ 31 | "obsidian", 32 | ...builtins, 33 | ], 34 | format: "cjs", 35 | target: "ES6", 36 | logLevel: "info", 37 | sourcemap: prodBuild ? false : "inline", 38 | treeShaking: true, 39 | minify: prodBuild, 40 | plugins: [ 41 | sassPlugin({ 42 | syntax: "scss", 43 | style: prodBuild ? "compressed" : "expanded", 44 | }), 45 | ], 46 | drop: prodBuild ? ["console"] : [] 47 | }; 48 | 49 | try { 50 | if (prodBuild) { 51 | await esbuild.build(buildOptions); 52 | } else { 53 | await (await esbuild.context(buildOptions)).watch(); 54 | } 55 | 56 | } catch { 57 | process.exit(1); 58 | } 59 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "unicode-search", 3 | "name": "Unicode Search", 4 | "version": "0.7.1", 5 | "minAppVersion": "1.8.7", 6 | "description": "Search and insert Unicode characters into your editor", 7 | "author": "BambusControl", 8 | "authorUrl": "https://github.com/BambusControl", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-unicode-search", 3 | "version": "0.7.1", 4 | "description": "Obsidian plugin for searching Unicode characters.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "export-schema": "typescript-json-schema --required --strictNullChecks --validationKeywords --ignoreErrors --refs --id https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json --out ./resources/save-data-schema.json tsconfig.json SaveData", 10 | "test": "jest" 11 | }, 12 | "keywords": [ 13 | "obsidian" 14 | ], 15 | "private": true, 16 | "repository": "https://github.com/BambusControl/obsidian-unicode-search", 17 | "author": { 18 | "name": "BambusControl", 19 | "url": "https://github.com/BambusControl" 20 | }, 21 | "license": "Apache-2.0", 22 | "dependencies": { 23 | "papaparse": "^5.5.1" 24 | }, 25 | "devDependencies": { 26 | "@types/jest": "^29", 27 | "@types/node": "^18", 28 | "@types/papaparse": "^5", 29 | "@typescript-eslint/eslint-plugin": "^8", 30 | "@typescript-eslint/parser": "^8", 31 | "builtin-modules": "^4", 32 | "esbuild": "^0", 33 | "esbuild-sass-plugin": "^3", 34 | "jest": "^29", 35 | "obsidian": "1.8.7", 36 | "ts-jest": "^29", 37 | "tslib": "^2", 38 | "typescript": "^5", 39 | "typescript-json-schema": "^0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /resources/save-data-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json", 3 | "$schema": "http://json-schema.org/draft-07/schema#", 4 | "definitions": { 5 | "BlockFilter": { 6 | "allOf": [ 7 | { 8 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/CodepointInterval" 9 | }, 10 | { 11 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/InclusionFlag" 12 | } 13 | ], 14 | "description": "Block of Unicode Codepoints" 15 | }, 16 | "CategoryFilter": { 17 | "description": "Filter for a single category.", 18 | "properties": { 19 | "abbreviation": { 20 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/CharacterCategoryType", 21 | "description": "Two letter abbreviation of the category." 22 | }, 23 | "included": { 24 | "description": "Indicates whether a character is included in search or not", 25 | "type": "boolean" 26 | } 27 | }, 28 | "required": [ 29 | "abbreviation", 30 | "included" 31 | ], 32 | "type": "object" 33 | }, 34 | "CategoryGroupFilter": { 35 | "description": "Filters for a group of categories.", 36 | "properties": { 37 | "abbreviation": { 38 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/CharacterCategoryGroupType", 39 | "description": "Single letter abbreviation of the category group." 40 | }, 41 | "categories": { 42 | "description": "Categories which belong to the group.", 43 | "items": { 44 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/CategoryFilter" 45 | }, 46 | "type": "array" 47 | } 48 | }, 49 | "required": [ 50 | "abbreviation", 51 | "categories" 52 | ], 53 | "type": "object" 54 | }, 55 | "CharacterCategoryGroupType": { 56 | "enum": [ 57 | "C", 58 | "L", 59 | "M", 60 | "N", 61 | "P", 62 | "S", 63 | "Z" 64 | ], 65 | "type": "string" 66 | }, 67 | "CharacterCategoryType": { 68 | "enum": [ 69 | "Cc", 70 | "Cf", 71 | "Cn", 72 | "Co", 73 | "Cs", 74 | "Ll", 75 | "Lm", 76 | "Lo", 77 | "Lt", 78 | "Lu", 79 | "Mc", 80 | "Me", 81 | "Mn", 82 | "Nd", 83 | "Nl", 84 | "No", 85 | "Pc", 86 | "Pd", 87 | "Pe", 88 | "Pf", 89 | "Pi", 90 | "Po", 91 | "Ps", 92 | "Sc", 93 | "Sk", 94 | "Sm", 95 | "So", 96 | "Zl", 97 | "Zp", 98 | "Zs" 99 | ], 100 | "type": "string" 101 | }, 102 | "CharacterUseFragment": { 103 | "description": "User generated usage data", 104 | "properties": { 105 | "codepoints": { 106 | "description": "Statistics of the individual codepoint usage", 107 | "items": { 108 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/RawCodepointUse" 109 | }, 110 | "type": "array" 111 | }, 112 | "initialized": { 113 | "type": "boolean" 114 | }, 115 | "version": { 116 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/SaveDataVersion" 117 | } 118 | }, 119 | "required": [ 120 | "codepoints", 121 | "initialized", 122 | "version" 123 | ], 124 | "type": "object" 125 | }, 126 | "CodepointAttribute": { 127 | "description": "General attributes of a Unicode codepoint", 128 | "properties": { 129 | "category": { 130 | "description": "Unicode category of the character", 131 | "type": "string" 132 | }, 133 | "name": { 134 | "description": "Unicode provided description of the character", 135 | "type": "string" 136 | } 137 | }, 138 | "required": [ 139 | "category", 140 | "name" 141 | ], 142 | "type": "object" 143 | }, 144 | "CodepointInterval": { 145 | "description": "Represents a closed interval/range of Unicode Code Points", 146 | "properties": { 147 | "end": { 148 | "type": "number" 149 | }, 150 | "start": { 151 | "type": "number" 152 | } 153 | }, 154 | "required": [ 155 | "end", 156 | "start" 157 | ], 158 | "type": "object" 159 | }, 160 | "CodepointKey": { 161 | "description": "Universally used key for a Unicode codepoint", 162 | "properties": { 163 | "codepoint": { 164 | "description": "A single character defined by a Unicode code point", 165 | "maxLength": 1, 166 | "minLength": 1, 167 | "type": "string" 168 | } 169 | }, 170 | "required": [ 171 | "codepoint" 172 | ], 173 | "type": "object" 174 | }, 175 | "FavoriteInfo": { 176 | "description": "Raw favorite information, as stored in save data", 177 | "properties": { 178 | "added": { 179 | "description": "Date when the codepoint was added to favorites", 180 | "type": "string" 181 | }, 182 | "hotkey": { 183 | "description": "Whether the codepoint has a hotkey command", 184 | "type": "boolean" 185 | } 186 | }, 187 | "required": [ 188 | "added", 189 | "hotkey" 190 | ], 191 | "type": "object" 192 | }, 193 | "FavoritesFragment": { 194 | "description": "Users favorite codepoints", 195 | "properties": { 196 | "codepoints": { 197 | "description": "List of favorite codepoints", 198 | "items": { 199 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/RawCodepointFavorite" 200 | }, 201 | "type": "array" 202 | }, 203 | "initialized": { 204 | "type": "boolean" 205 | }, 206 | "version": { 207 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/SaveDataVersion" 208 | } 209 | }, 210 | "required": [ 211 | "codepoints", 212 | "initialized", 213 | "version" 214 | ], 215 | "type": "object" 216 | }, 217 | "FilterFragment": { 218 | "description": "User saved character filters", 219 | "properties": { 220 | "initialized": { 221 | "type": "boolean" 222 | }, 223 | "unicode": { 224 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/UnicodeFilter", 225 | "description": "Filter criteria for Unicode characters" 226 | }, 227 | "version": { 228 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/SaveDataVersion" 229 | } 230 | }, 231 | "required": [ 232 | "initialized", 233 | "unicode", 234 | "version" 235 | ], 236 | "type": "object" 237 | }, 238 | "InclusionFlag": { 239 | "description": "A flag indicating whether a character is included in search or not", 240 | "properties": { 241 | "included": { 242 | "description": "Indicates whether a character is included in search or not", 243 | "type": "boolean" 244 | } 245 | }, 246 | "required": [ 247 | "included" 248 | ], 249 | "type": "object" 250 | }, 251 | "MetaFragment": { 252 | "description": "Meta information for the datastore", 253 | "properties": { 254 | "events": { 255 | "description": "A collection of unique data events.", 256 | "items": { 257 | "const": "download_characters", 258 | "type": "string" 259 | }, 260 | "type": "array" 261 | }, 262 | "initialized": { 263 | "type": "boolean" 264 | }, 265 | "pluginVersion": { 266 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/PluginVersion", 267 | "description": "Latest used version of the plugin which used this data" 268 | }, 269 | "version": { 270 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/SaveDataVersion" 271 | } 272 | }, 273 | "required": [ 274 | "events", 275 | "initialized", 276 | "pluginVersion", 277 | "version" 278 | ], 279 | "type": "object" 280 | }, 281 | "PlaneFilter": { 282 | "description": "Unicode Plane of Unicode Blocks", 283 | "properties": { 284 | "blocks": { 285 | "description": "Filter criteria for blocks of Unicode characters", 286 | "items": { 287 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/BlockFilter" 288 | }, 289 | "type": "array" 290 | }, 291 | "end": { 292 | "type": "number" 293 | }, 294 | "start": { 295 | "type": "number" 296 | } 297 | }, 298 | "required": [ 299 | "blocks", 300 | "end", 301 | "start" 302 | ], 303 | "type": "object" 304 | }, 305 | "PluginVersion": { 306 | "description": "Version of the plugin.\n\nMust comply with RegEx:\n```^[0-9]+\\\\.[0-9]+\\\\.[0-9]+(?:-[A-Z]+)?$```", 307 | "enum": [ 308 | "0.2.0", 309 | "0.2.1", 310 | "0.2.2", 311 | "0.2.3", 312 | "0.3.0", 313 | "0.4.0", 314 | "0.4.1", 315 | "0.5.0", 316 | "0.6.0", 317 | "0.6.1", 318 | "0.7.0", 319 | "0.7.1" 320 | ], 321 | "type": "string" 322 | }, 323 | "RawCodepointFavorite": { 324 | "allOf": [ 325 | { 326 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/CodepointKey" 327 | }, 328 | { 329 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/FavoriteInfo" 330 | } 331 | ], 332 | "description": "Favorite infor of a specific codepoint as stored in save data" 333 | }, 334 | "RawCodepointUse": { 335 | "allOf": [ 336 | { 337 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/CodepointKey" 338 | }, 339 | { 340 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/RawUsageInfo" 341 | } 342 | ], 343 | "description": "Usage information of a specific codepoint as stored in save data" 344 | }, 345 | "RawUsageInfo": { 346 | "description": "Raw usage information, as stored in save data", 347 | "properties": { 348 | "firstUsed": { 349 | "description": "Alias to hint that a string is a date string", 350 | "type": "string" 351 | }, 352 | "lastUsed": { 353 | "description": "Alias to hint that a string is a date string", 354 | "type": "string" 355 | }, 356 | "useCount": { 357 | "type": "number" 358 | } 359 | }, 360 | "required": [ 361 | "firstUsed", 362 | "lastUsed", 363 | "useCount" 364 | ], 365 | "type": "object" 366 | }, 367 | "SaveDataVersion": { 368 | "description": "Version of the save data schema.\n\nMust comply with RegEx:\n```^[0-9]+\\\\.[0-9]+\\\\.[0-9]+(?:-[A-Z]+)?$```\n\nThe version of the save data schema is independent of the plugin version", 369 | "enum": [ 370 | "0.4.0", 371 | "0.5.0", 372 | "0.6.0", 373 | "0.7.0" 374 | ], 375 | "type": "string" 376 | }, 377 | "UnicodeCodepoint": { 378 | "allOf": [ 379 | { 380 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/CodepointKey" 381 | }, 382 | { 383 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/CodepointAttribute" 384 | } 385 | ], 386 | "description": "Unicode codepoint representation throughout the plugin" 387 | }, 388 | "UnicodeFilter": { 389 | "description": "User set filter data for Unicode characters.", 390 | "properties": { 391 | "categoryGroups": { 392 | "description": "Filter criteria for category groups of Unicode characters", 393 | "items": { 394 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/CategoryGroupFilter" 395 | }, 396 | "type": "array" 397 | }, 398 | "planes": { 399 | "description": "Filter criteria for planes of Unicode characters", 400 | "items": { 401 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/PlaneFilter" 402 | }, 403 | "type": "array" 404 | } 405 | }, 406 | "required": [ 407 | "categoryGroups", 408 | "planes" 409 | ], 410 | "type": "object" 411 | }, 412 | "UnicodeFragment": { 413 | "description": "Downloaded Unicode Character Database", 414 | "properties": { 415 | "codepoints": { 416 | "description": "Codepoints downloaded from the Unicode Character Database", 417 | "items": { 418 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/UnicodeCodepoint" 419 | }, 420 | "type": "array" 421 | }, 422 | "initialized": { 423 | "type": "boolean" 424 | }, 425 | "version": { 426 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/SaveDataVersion" 427 | } 428 | }, 429 | "required": [ 430 | "codepoints", 431 | "initialized", 432 | "version" 433 | ], 434 | "type": "object" 435 | } 436 | }, 437 | "description": "Structure of `data.json`, where each fragment is a self-managed data fragment", 438 | "properties": { 439 | "characters": { 440 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/UnicodeFragment", 441 | "description": "Local character database" 442 | }, 443 | "favorites": { 444 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/FavoritesFragment", 445 | "description": "Favorites saved manually by the user" 446 | }, 447 | "filter": { 448 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/FilterFragment", 449 | "description": "Filtering of downloaded/displayed codepoints" 450 | }, 451 | "meta": { 452 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/MetaFragment", 453 | "description": "Metadata information about the save data itself" 454 | }, 455 | "usage": { 456 | "$ref": "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/0.7.1/resources/save-data-schema.json#/definitions/CharacterUseFragment", 457 | "description": "Usage information generated by the user" 458 | } 459 | }, 460 | "required": [ 461 | "characters", 462 | "favorites", 463 | "filter", 464 | "meta", 465 | "usage" 466 | ], 467 | "type": "object" 468 | } 469 | 470 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=BambusControl_obsidian-unicode-search 2 | sonar.organization=bambuscontrol 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | sonar.projectVersion=0.7.1 6 | 7 | 8 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 9 | sonar.sources=./src 10 | sonar.tests=./tests 11 | 12 | # Encoding of the source code. Default is default system encoding 13 | sonar.sourceEncoding=UTF-8 14 | 15 | # Path for coverage reports 16 | sonar.javascript.lcov.reportPaths=./coverage/lcov.info 17 | -------------------------------------------------------------------------------- /src/libraries/comparison/compareCharacterMatches.ts: -------------------------------------------------------------------------------- 1 | import {MaybeMetaCharacterSearchResult} from "../../unicode-search/components/characterSearch"; 2 | import {Order} from "../order/order"; 3 | import {compareSearchMatches} from "./compareSearchMatches"; 4 | import {compareCharacters} from "./compareCharacters"; 5 | 6 | export function compareCharacterMatches( 7 | left: MaybeMetaCharacterSearchResult, 8 | right: MaybeMetaCharacterSearchResult, 9 | recencyCutoff: Date, 10 | ): Order { 11 | const matchComparison = compareSearchMatches(left.match, right.match); 12 | 13 | if (matchComparison !== Order.Equal) { 14 | return matchComparison; 15 | } 16 | 17 | return compareCharacters(left.character, right.character, recencyCutoff); 18 | } 19 | 20 | -------------------------------------------------------------------------------- /src/libraries/comparison/compareCharacters.ts: -------------------------------------------------------------------------------- 1 | import {MetadataCharacter} from "../types/codepoint/character"; 2 | import {Order} from "../order/order"; 3 | import {compareFavoriteCharacters} from "./compareFavoriteCharacters"; 4 | import {compareUsedCharacters} from "./compareUsedCharacters"; 5 | import {compareCodepoints} from "./compareCodepoints"; 6 | 7 | export function compareCharacters( 8 | left: MetadataCharacter, 9 | right: MetadataCharacter, 10 | recencyCutoff: Date, 11 | ): Order { 12 | const usedComparison = compareUsedCharacters(left, right, recencyCutoff); 13 | 14 | if (usedComparison !== Order.Equal) { 15 | return usedComparison; 16 | } 17 | 18 | const favoriteComparison = compareFavoriteCharacters(left, right); 19 | 20 | if (favoriteComparison !== Order.Equal) { 21 | return favoriteComparison; 22 | } 23 | 24 | return compareCodepoints(left, right); 25 | } 26 | -------------------------------------------------------------------------------- /src/libraries/comparison/compareCodepoints.ts: -------------------------------------------------------------------------------- 1 | import {UnicodeCodepoint} from "../types/codepoint/unicode"; 2 | import {Order} from "../order/order"; 3 | import {compareNumbers} from "./compareNumbers"; 4 | 5 | export function compareCodepoints( 6 | left: UnicodeCodepoint, 7 | right: UnicodeCodepoint, 8 | ): Order { 9 | return compareNumbers(left.codepoint.codePointAt(0)!, right.codepoint.codePointAt(0)!) 10 | } 11 | -------------------------------------------------------------------------------- /src/libraries/comparison/compareDates.ts: -------------------------------------------------------------------------------- 1 | import {Order} from "../order/order"; 2 | 3 | export function compareDates(left: Date, right: Date): Order { 4 | if (left < right) { 5 | return Order.Smaller; 6 | } 7 | 8 | if (left > right) { 9 | return Order.Greater; 10 | } 11 | 12 | return Order.Equal; 13 | } 14 | -------------------------------------------------------------------------------- /src/libraries/comparison/compareFavoriteCharacters.ts: -------------------------------------------------------------------------------- 1 | import {FavoriteCharacter, MaybeFavoriteCharacter, MaybeUsedCharacter} from "../types/codepoint/character"; 2 | import {isFavoriteCharacter} from "../helpers/isFavoriteCharacter"; 3 | import {Order} from "../order/order"; 4 | import {compareNullable} from "./compareNullable"; 5 | import {compareFavoriteInfo} from "./compareFavoriteInfo"; 6 | 7 | function toFavoriteCharacter(character: MaybeUsedCharacter): FavoriteCharacter | null { 8 | return isFavoriteCharacter(character) ? character : null; 9 | } 10 | 11 | export function compareFavoriteCharacters( 12 | left: MaybeFavoriteCharacter, 13 | right: MaybeFavoriteCharacter, 14 | ): Order { 15 | return compareNullable( 16 | toFavoriteCharacter(left), 17 | toFavoriteCharacter(right), 18 | (l, r) => compareFavoriteInfo(l, r), 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/libraries/comparison/compareFavoriteInfo.ts: -------------------------------------------------------------------------------- 1 | import {Order} from "../order/order"; 2 | import {inverse} from "../order/inverse"; 3 | import {compareDates} from "./compareDates"; 4 | import {ParsedFavoriteInfo} from "../types/savedata/favoriteInfo"; 5 | 6 | export function compareFavoriteInfo( 7 | left: ParsedFavoriteInfo, 8 | right: ParsedFavoriteInfo, 9 | ): Order { 10 | // We want the most recently added to be first. 11 | return inverse(compareDates(left.added, right.added)); 12 | } 13 | -------------------------------------------------------------------------------- /src/libraries/comparison/compareNullable.ts: -------------------------------------------------------------------------------- 1 | import {Order} from "../order/order"; 2 | 3 | export function compareNullable( 4 | left: T | null | undefined, 5 | right: T | null | undefined, 6 | compareFn: (a: T, b: T) => Order, 7 | ): Order { 8 | if (left == null && right == null) { 9 | return Order.Equal; 10 | } 11 | 12 | if (left != null && right != null) { 13 | return compareFn(left, right); 14 | } 15 | 16 | return left == null 17 | ? Order.After 18 | : Order.Before; 19 | } 20 | -------------------------------------------------------------------------------- /src/libraries/comparison/compareNumbers.ts: -------------------------------------------------------------------------------- 1 | import {Order} from "../order/order"; 2 | 3 | export function compareNumbers(left: number, right: number): Order { 4 | if (left === right) { 5 | return Order.Equal; 6 | } 7 | 8 | return left < right 9 | ? Order.Smaller 10 | : Order.Greater; 11 | } 12 | -------------------------------------------------------------------------------- /src/libraries/comparison/compareSearchMatchScores.ts: -------------------------------------------------------------------------------- 1 | import {SearchMatchAttributes} from "../../unicode-search/components/characterSearch"; 2 | import {Order} from "../order/order"; 3 | 4 | export function compareSearchMatchScores(left: SearchMatchAttributes, right: SearchMatchAttributes): Order { 5 | /* Matches are scored with negative values up to 0, with 0 meaning full match for fuzzy search */ 6 | const codepointScore = right.codepoint.score - left.codepoint.score; 7 | const nameScore = right.name.score - left.name.score; 8 | const value = codepointScore + nameScore; 9 | const nValue = value / Math.abs(value); 10 | 11 | return nValue as Order; 12 | } 13 | -------------------------------------------------------------------------------- /src/libraries/comparison/compareSearchMatches.ts: -------------------------------------------------------------------------------- 1 | import {MaybeSearchMatchAttributes} from "../../unicode-search/components/characterSearch"; 2 | import {Order} from "../order/order"; 3 | import {compareSearchMatchScores} from "./compareSearchMatchScores"; 4 | import {compareNullable} from "./compareNullable"; 5 | import {fillNullSearchMatchScores} from "./fillNullSearchMatchScores"; 6 | 7 | export function compareSearchMatches(left: MaybeSearchMatchAttributes, right: MaybeSearchMatchAttributes): Order { 8 | const leftNull = left.codepoint == null && left.name == null; 9 | const rightNull = right.codepoint == null && right.name == null; 10 | 11 | return compareNullable( 12 | leftNull ? null : fillNullSearchMatchScores(left), 13 | rightNull ? null : fillNullSearchMatchScores(right), 14 | (l, r) => compareSearchMatchScores(l, r) 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/libraries/comparison/compareUsageInfo.ts: -------------------------------------------------------------------------------- 1 | import {Order} from "../order/order"; 2 | import {inverse} from "../order/inverse"; 3 | import {compareNumbers} from "./compareNumbers"; 4 | 5 | import {compareDates} from "./compareDates"; 6 | import {compareNullable} from "./compareNullable"; 7 | 8 | 9 | import {UsageInfo} from "../types/savedata/usageInfo"; 10 | 11 | export function compareUsageInfo( 12 | left: UsageInfo, 13 | right: UsageInfo, 14 | recencyCutoff: Date, 15 | ): Order { 16 | // We want the most recently used to be first. 17 | const lastUsedComparison = compareNullable( 18 | left.lastUsed < recencyCutoff ? null : left.lastUsed, 19 | right.lastUsed < recencyCutoff ? null : right.lastUsed, 20 | (l, r) => inverse(compareDates(l, r)) 21 | ); 22 | 23 | if (lastUsedComparison !== Order.Equal) { 24 | return lastUsedComparison; 25 | } 26 | 27 | // We want the most used to be before the less used. 28 | return inverse(compareNumbers(left.useCount, right.useCount)); 29 | } 30 | -------------------------------------------------------------------------------- /src/libraries/comparison/compareUsedCharacters.ts: -------------------------------------------------------------------------------- 1 | import {Order} from "../order/order"; 2 | import {compareNullable} from "./compareNullable"; 3 | import {compareUsageInfo} from "./compareUsageInfo"; 4 | import {MaybeUsedCharacter, UsedCharacter} from "../types/codepoint/character"; 5 | import {isUsedCharacter} from "../helpers/isUsedCharacter"; 6 | 7 | function toUsedCharacter(character: MaybeUsedCharacter): UsedCharacter | null { 8 | return isUsedCharacter(character) ? character : null; 9 | } 10 | 11 | export function compareUsedCharacters( 12 | left: MaybeUsedCharacter, 13 | right: MaybeUsedCharacter, 14 | recencyCutoff: Date, 15 | ): Order { 16 | return compareNullable( 17 | toUsedCharacter(left), 18 | toUsedCharacter(right), 19 | (l, r) => compareUsageInfo(l, r, recencyCutoff), 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/libraries/comparison/fillNullCharacterMatchScores.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MaybeMetaCharacterSearchResult, 3 | MetaCharacterSearchResult 4 | } from "../../unicode-search/components/characterSearch"; 5 | import {fillNullSearchMatchScores} from "./fillNullSearchMatchScores"; 6 | 7 | export function fillNullCharacterMatchScores(characterMatch: MaybeMetaCharacterSearchResult): MetaCharacterSearchResult { 8 | return { 9 | ...characterMatch, 10 | match: fillNullSearchMatchScores(characterMatch.match), 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/libraries/comparison/fillNullSearchMatchScores.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MaybeSearchMatchAttributes, 3 | NONE_RESULT, 4 | SearchMatchAttributes 5 | } from "../../unicode-search/components/characterSearch"; 6 | 7 | export function fillNullSearchMatchScores(match: MaybeSearchMatchAttributes): SearchMatchAttributes { 8 | return { 9 | name: match.name ?? NONE_RESULT, 10 | codepoint: match.codepoint ?? NONE_RESULT 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/libraries/data/characterCategory.ts: -------------------------------------------------------------------------------- 1 | //noinspection JSUnusedGlobalSymbols 2 | 3 | export enum CharacterCategoryLetter { 4 | Lowercase = "Ll", 5 | Modifier = "Lm", 6 | Titlecase = "Lt", 7 | Uppercase = "Lu", 8 | Other = "Lo", 9 | } 10 | 11 | export enum CharacterCategoryMark { 12 | SpacingCombining = "Mc", 13 | Enclosing = "Me", 14 | NonSpacing = "Mn", 15 | } 16 | 17 | export enum CharacterCategoryNumber { 18 | DecimalDigit = "Nd", 19 | Letter = "Nl", 20 | Other = "No", 21 | } 22 | 23 | export enum CharacterCategoryPunctuation { 24 | Connector = "Pc", 25 | Dash = "Pd", 26 | InitialQuote = "Pi", 27 | FinalQuote = "Pf", 28 | Open = "Ps", 29 | Close = "Pe", 30 | Other = "Po", 31 | } 32 | 33 | export enum CharacterCategorySymbol { 34 | Currency = "Sc", 35 | Modifier = "Sk", 36 | Math = "Sm", 37 | Other = "So", 38 | } 39 | 40 | export enum CharacterCategorySeparator { 41 | Line = "Zl", 42 | Paragraph = "Zp", 43 | Space = "Zs", 44 | } 45 | 46 | export enum CharacterCategoryOther { 47 | Control = "Cc", 48 | Format = "Cf", 49 | NotAssigned = "Cn", 50 | PrivateUse = "Co", 51 | Surrogate = "Cs", 52 | } 53 | 54 | export type CharacterCategory 55 | = CharacterCategoryLetter 56 | | CharacterCategoryMark 57 | | CharacterCategoryNumber 58 | | CharacterCategoryPunctuation 59 | | CharacterCategorySymbol 60 | | CharacterCategorySeparator 61 | | CharacterCategoryOther 62 | 63 | export type CharacterCategoryType = 64 | "Ll" | "Lm" | "Lt" | "Lu" | "Lo" 65 | | "Mc" | "Me" | "Mn" 66 | | "Nd" | "Nl" | "No" 67 | | "Pc" | "Pd" | "Pi" | "Pf" | "Ps" | "Pe" | "Po" 68 | | "Sc" | "Sk" | "Sm" | "So" 69 | | "Zl" | "Zp" | "Zs" 70 | | "Cc" | "Cf" | "Cn" | "Co" | "Cs" 71 | -------------------------------------------------------------------------------- /src/libraries/data/characterCategoryGroup.ts: -------------------------------------------------------------------------------- 1 | //noinspection JSUnusedGlobalSymbols 2 | 3 | export enum CharacterCategoryGroup { 4 | Letter = "L", 5 | Mark = "M", 6 | Number = "N", 7 | Punctuation = "P", 8 | Symbol = "S", 9 | Separator = "Z", 10 | Other = "C", 11 | } 12 | 13 | export type CharacterCategoryGroupType = "L" | "M" | "N" | "P" | "S" | "Z" | "C"; 14 | -------------------------------------------------------------------------------- /src/libraries/data/unicodeCharacterCategories.ts: -------------------------------------------------------------------------------- 1 | import {UnicodeGeneralCategoryGroup} from "../types/unicode/unicodeGeneralCategoryGroup"; 2 | 3 | export const UNICODE_CHARACTER_CATEGORIES: UnicodeGeneralCategoryGroup[] = [ 4 | { 5 | abbreviation: "L", 6 | name: "Letter", 7 | categories: [ 8 | { 9 | abbreviation: "Lu", 10 | name: "Uppercase Letter", 11 | description: "an uppercase letter", 12 | }, 13 | 14 | { 15 | abbreviation: "Ll", 16 | name: "Lowercase Letter", 17 | description: "a lowercase letter", 18 | }, 19 | 20 | { 21 | abbreviation: "Lt", 22 | name: "Titlecase Letter", 23 | description: "a digraph encoded as a single character, with first part uppercase", 24 | }, 25 | 26 | { 27 | abbreviation: "Lm", 28 | name: "Modifier Letter", 29 | description: "a modifier letter", 30 | }, 31 | 32 | { 33 | abbreviation: "Lo", 34 | name: "Other Letter", 35 | description: "other letters, including syllables and ideographs", 36 | }, 37 | ] 38 | }, 39 | { 40 | abbreviation: "M", 41 | name: "Mark", 42 | categories: [ 43 | { 44 | abbreviation: "Mn", 45 | name: "Nonspacing Mark", 46 | description: "a nonspacing combining mark (zero advance width)", 47 | }, 48 | 49 | { 50 | abbreviation: "Mc", 51 | name: "Spacing Mark", 52 | description: "a spacing combining mark (positive advance width)", 53 | }, 54 | 55 | { 56 | abbreviation: "Me", 57 | name: "Enclosing Mark", 58 | description: "an enclosing combining mark", 59 | }, 60 | ], 61 | }, 62 | 63 | { 64 | abbreviation: "N", 65 | name: "Number", 66 | categories: [ 67 | { 68 | abbreviation: "Nd", 69 | name: "Decimal Number", 70 | description: "a decimal digit", 71 | }, 72 | 73 | { 74 | abbreviation: "Nl", 75 | name: "Letter Number", 76 | description: "a letterlike numeric character", 77 | }, 78 | 79 | { 80 | abbreviation: "No", 81 | name: "Other Number", 82 | description: "a numeric character of other type", 83 | }, 84 | ] 85 | }, 86 | { 87 | abbreviation: "P", 88 | name: "Punctuation", 89 | categories: [ 90 | { 91 | abbreviation: "Pc", 92 | name: "Connector Punctuation", 93 | description: "a connecting punctuation mark, like a tie", 94 | }, 95 | 96 | { 97 | abbreviation: "Pd", 98 | name: "Dash Punctuation", 99 | description: "a dash or hyphen punctuation mark", 100 | }, 101 | 102 | { 103 | abbreviation: "Ps", 104 | name: "Open Punctuation", 105 | description: "an opening punctuation mark (of a pair)", 106 | }, 107 | 108 | { 109 | abbreviation: "Pe", 110 | name: "Close Punctuation", 111 | description: "a closing punctuation mark (of a pair)", 112 | }, 113 | 114 | { 115 | abbreviation: "Pi", 116 | name: "Initial Punctuation", 117 | description: "an initial quotation mark", 118 | }, 119 | 120 | { 121 | abbreviation: "Pf", 122 | name: "Final Punctuation", 123 | description: "a final quotation mark", 124 | }, 125 | 126 | { 127 | abbreviation: "Po", 128 | name: "Other Punctuation", 129 | description: "a punctuation mark of other type", 130 | }, 131 | ] 132 | }, 133 | { 134 | abbreviation: "S", 135 | name: "Symbol", 136 | categories: [ 137 | { 138 | abbreviation: "Sm", 139 | name: "Math Symbol", 140 | description: "a symbol of mathematical use", 141 | }, 142 | 143 | { 144 | abbreviation: "Sc", 145 | name: "Currency Symbol", 146 | description: "a currency sign", 147 | }, 148 | 149 | { 150 | abbreviation: "Sk", 151 | name: "Modifier Symbol", 152 | description: "a non-letterlike modifier symbol", 153 | }, 154 | { 155 | abbreviation: "So", 156 | name: "Other Symbol", 157 | description: "a symbol of other type", 158 | }, 159 | ] 160 | }, 161 | 162 | { 163 | abbreviation: "Z", 164 | name: "Separator", 165 | categories: [ 166 | { 167 | abbreviation: "Zs", 168 | name: "Space Separator", 169 | description: "a space character (of various non-zero widths)", 170 | }, 171 | 172 | { 173 | abbreviation: "Zl", 174 | name: "Line Separator", 175 | description: "U+2028 LINE SEPARATOR only", 176 | }, 177 | 178 | { 179 | abbreviation: "Zp", 180 | name: "Paragraph Separator", 181 | description: "U+2029 PARAGRAPH SEPARATOR only", 182 | }, 183 | ] 184 | }, 185 | { 186 | abbreviation: "C", 187 | name: "Other", 188 | categories: [ 189 | { 190 | abbreviation: "Cc", 191 | name: "Control", 192 | description: "a C0 or C1 control code", 193 | }, 194 | 195 | { 196 | abbreviation: "Cf", 197 | name: "Format", 198 | description: "a format control character", 199 | }, 200 | 201 | { 202 | abbreviation: "Cs", 203 | name: "Surrogate", 204 | description: "a surrogate code point", 205 | }, 206 | 207 | { 208 | abbreviation: "Co", 209 | name: "Private Use", 210 | description: "a private-use character", 211 | }, 212 | 213 | { 214 | abbreviation: "Cn", 215 | name: "Unassigned", 216 | description: "a reserved unassigned code point or a noncharacter", 217 | }, 218 | ] 219 | }, 220 | ]; 221 | -------------------------------------------------------------------------------- /src/libraries/data/unicodePlaneNumber.ts: -------------------------------------------------------------------------------- 1 | export type UnicodePlaneNumber = 0 | 1 | 2 | 3 | 14 | 15 | 16; 2 | -------------------------------------------------------------------------------- /src/libraries/helpers/asHexadecimal.ts: -------------------------------------------------------------------------------- 1 | export function asHexadecimal(n: number): string { 2 | return n.toString(16).padStart(4, "0"); 3 | } 4 | -------------------------------------------------------------------------------- /src/libraries/helpers/averageUseCount.ts: -------------------------------------------------------------------------------- 1 | import {UsageCount} from "../types/savedata/usageInfo"; 2 | 3 | export function averageUseCount(items: UsageCount[]): number { 4 | const result = items.reduce( 5 | (acc, item) => ({ 6 | totalUses: acc.totalUses + item.useCount, 7 | itemCount: acc.itemCount + 1 8 | }), 9 | {totalUses: 0, itemCount: 0} 10 | ); 11 | 12 | const {totalUses, itemCount} = result; 13 | return itemCount === 0 ? 0 : totalUses / itemCount; 14 | } 15 | -------------------------------------------------------------------------------- /src/libraries/helpers/codePointIn.ts: -------------------------------------------------------------------------------- 1 | import {Codepoint} from "../types/codepoint/unicode"; 2 | import {CodepointInterval} from "../types/codepoint/codepointInterval"; 3 | 4 | export function codepointIn(codepoint: Codepoint, interval: CodepointInterval): boolean { 5 | return codepoint >= interval.start 6 | && codepoint <= interval.end; 7 | } 8 | -------------------------------------------------------------------------------- /src/libraries/helpers/getRandomItem.ts: -------------------------------------------------------------------------------- 1 | import {UnicodeSearchError} from "../../unicode-search/errors/unicodeSearchError"; 2 | 3 | export function getRandomItem(items: T[]): T { 4 | if (items.length < 1) { 5 | throw new UnicodeSearchError("Cannot get a random item from an empty array") 6 | } 7 | 8 | return items[Math.floor(Math.random() * items.length)]; 9 | } 10 | -------------------------------------------------------------------------------- /src/libraries/helpers/intervalWithin.ts: -------------------------------------------------------------------------------- 1 | import {CodepointInterval} from "../types/codepoint/codepointInterval"; 2 | 3 | export function intervalWithin(outer: CodepointInterval, inner: CodepointInterval): boolean { 4 | return outer.start <= inner.start 5 | && outer.end >= inner.end; 6 | } 7 | -------------------------------------------------------------------------------- /src/libraries/helpers/intervalsEqual.ts: -------------------------------------------------------------------------------- 1 | import {CodepointInterval} from "../types/codepoint/codepointInterval"; 2 | 3 | export function intervalsEqual(left: CodepointInterval, right: CodepointInterval): boolean { 4 | return left.start === right.start 5 | && left.end === right.end; 6 | } 7 | -------------------------------------------------------------------------------- /src/libraries/helpers/isFavoriteCharacter.ts: -------------------------------------------------------------------------------- 1 | import {FavoriteCharacter, MaybeFavoriteCharacter} from "../types/codepoint/character"; 2 | 3 | export function isFavoriteCharacter(character: MaybeFavoriteCharacter): character is FavoriteCharacter { 4 | return character != null 5 | && "added" in character 6 | && "hotkey" in character 7 | ; 8 | } 9 | -------------------------------------------------------------------------------- /src/libraries/helpers/isTypeSaveData.ts: -------------------------------------------------------------------------------- 1 | import {DataFragment} from "../types/savedata/dataFragment"; 2 | import {Char, CodepointKey} from "../types/codepoint/unicode"; 3 | 4 | 5 | export function isTypeDataFragment(object: any): object is DataFragment { 6 | return object != null 7 | && "initialized" in object 8 | && "version" in object 9 | ; 10 | } 11 | 12 | export function isCodepointKey(object: any): object is CodepointKey { 13 | return object != null 14 | && "codepoint" in object 15 | && isChar(object.codepoint); 16 | 17 | } 18 | export function isChar(object: any): object is Char { 19 | return object != null 20 | && typeof object === "string" 21 | && object.length === 1 22 | } 23 | -------------------------------------------------------------------------------- /src/libraries/helpers/isUsedCharacter.ts: -------------------------------------------------------------------------------- 1 | import {MaybeUsedCharacter, UsedCharacter} from "../types/codepoint/character"; 2 | 3 | export function isUsedCharacter(character: MaybeUsedCharacter): character is UsedCharacter { 4 | return character != null 5 | && "useCount" in character 6 | && "firstUsed" in character 7 | && "lastUsed" in character 8 | ; 9 | } 10 | -------------------------------------------------------------------------------- /src/libraries/helpers/matchedNameOrCodepoint.ts: -------------------------------------------------------------------------------- 1 | import {MaybeMetaCharacterSearchResult, MetaCharacterSearchResult} from "../../unicode-search/components/characterSearch"; 2 | 3 | export function matchedNameOrCodepoint(match: MetaCharacterSearchResult | MaybeMetaCharacterSearchResult) { 4 | return match.match.name != null || match.match.codepoint != null; 5 | } 6 | -------------------------------------------------------------------------------- /src/libraries/helpers/mergeIntervals.ts: -------------------------------------------------------------------------------- 1 | import {CodepointInterval} from "../types/codepoint/codepointInterval"; 2 | 3 | /** 4 | * @param codepointIntervals 5 | * @see https://www.geeksforgeeks.org/merging-intervals/ 6 | */ 7 | export function mergeIntervals(codepointIntervals: CodepointInterval[]): CodepointInterval[] { 8 | const intervals = Array.from(codepointIntervals); 9 | 10 | /* Sort intervals in increasing order of start time */ 11 | intervals.sort((a, b) => a.start - b.start); 12 | 13 | /* Stores index of last element in output array (modified intervals[]) */ 14 | let p = 0; 15 | 16 | for (let c = 1; c < intervals.length; c++) { 17 | const previous = intervals[p]; 18 | const current = intervals[c]; 19 | 20 | const overlaps = previous.end >= current.start; 21 | 22 | if (overlaps) { 23 | previous.end = Math.max(previous.end, current.end); 24 | } else { 25 | p++; 26 | intervals[p] = current; 27 | } 28 | } 29 | 30 | /* intervals[0 .. p - 1] stores the merged intervals */ 31 | return intervals.slice(0, p + 1); 32 | } 33 | -------------------------------------------------------------------------------- /src/libraries/helpers/mostRecentUses.ts: -------------------------------------------------------------------------------- 1 | import {compareDates} from "../comparison/compareDates"; 2 | import {inverse} from "../order/inverse"; 3 | 4 | 5 | import {UsageDate} from "../types/savedata/usageInfo"; 6 | 7 | export function mostRecentUses(items: UsageDate[]): Date[] { 8 | return items.slice() 9 | .map(value => value.lastUsed) 10 | .sort((l, r) => inverse(compareDates(l, r))); 11 | } 12 | -------------------------------------------------------------------------------- /src/libraries/helpers/parseUsageInfo.ts: -------------------------------------------------------------------------------- 1 | import {UsageInfo, RawUsageInfo} from "../types/savedata/usageInfo"; 2 | 3 | export function parseUsageInfo(value: T & RawUsageInfo): T & UsageInfo { 4 | return { 5 | ...value, 6 | lastUsed: new Date(value.lastUsed), 7 | firstUsed: new Date(value.firstUsed), 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/libraries/helpers/serializeFavoriteInfo.ts: -------------------------------------------------------------------------------- 1 | import {FavoriteInfo, ParsedFavoriteInfo} from "../types/savedata/favoriteInfo"; 2 | 3 | export function serializeFavoriteInfo(value: T & ParsedFavoriteInfo): T & FavoriteInfo { 4 | return { 5 | ...value, 6 | added: value.added.toJSON(), 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/libraries/helpers/serializeUsageInfo.ts: -------------------------------------------------------------------------------- 1 | import {UsageInfo, RawUsageInfo} from "../types/savedata/usageInfo"; 2 | 3 | export function serializeUsageInfo(value: T & UsageInfo): T & RawUsageInfo { 4 | return { 5 | ...value, 6 | lastUsed: value.lastUsed.toJSON(), 7 | firstUsed: value.firstUsed.toJSON(), 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/libraries/helpers/toHexadecimal.ts: -------------------------------------------------------------------------------- 1 | import {asHexadecimal} from "./asHexadecimal"; 2 | 3 | import {CodepointKey} from "../types/codepoint/unicode"; 4 | 5 | export function toHexadecimal(character: CodepointKey): string { 6 | /* Characters are expected to always have a single character */ 7 | return asHexadecimal(character.codepoint.codePointAt(0)!); 8 | } 9 | -------------------------------------------------------------------------------- /src/libraries/helpers/toNullMatch.ts: -------------------------------------------------------------------------------- 1 | import {MaybeUsedCharacter} from "../types/codepoint/character"; 2 | import {MaybeMetaCharacterSearchResult} from "../../unicode-search/components/characterSearch"; 3 | 4 | export function toNullMatch(character: MaybeUsedCharacter): MaybeMetaCharacterSearchResult { 5 | return { 6 | character: character, 7 | match: { 8 | codepoint: null, 9 | name: null 10 | } 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/libraries/helpers/toSearchQueryMatch.ts: -------------------------------------------------------------------------------- 1 | import {prepareFuzzySearch, prepareSimpleSearch} from "obsidian"; 2 | import {MaybeUsedCharacter} from "../types/codepoint/character"; 3 | import {MaybeMetaCharacterSearchResult} from "../../unicode-search/components/characterSearch"; 4 | import {toHexadecimal} from "./toHexadecimal"; 5 | 6 | export function toSearchQueryMatch(query: string) { 7 | const isHexSafe = query.length <= 4 && !query.contains(" "); 8 | 9 | const codepointSearch = isHexSafe ? prepareSimpleSearch(query) : ((_: string) => null); 10 | const fuzzyNameSearch = prepareFuzzySearch(query); 11 | 12 | return (character: MaybeUsedCharacter): MaybeMetaCharacterSearchResult => ({ 13 | character: character, 14 | match: { 15 | codepoint: codepointSearch(toHexadecimal(character)), 16 | name: fuzzyNameSearch(character.name) 17 | } 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/libraries/order/inverse.ts: -------------------------------------------------------------------------------- 1 | import {Order} from "./order"; 2 | 3 | export function inverse(order: Order): Order { 4 | switch (order) { 5 | case Order.Before: 6 | return Order.After; 7 | case Order.After: 8 | return Order.Before; 9 | default: 10 | return Order.Equal; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/libraries/order/order.ts: -------------------------------------------------------------------------------- 1 | export enum Order { 2 | 3 | /** 4 | * **First** argument is **smaller than** the **second** argument. 5 | */ 6 | Before = -1, 7 | Smaller = Before, 8 | 9 | /** 10 | * **First** argument is **equal to** the **second** argument. 11 | */ 12 | Equal = 0, 13 | 14 | /** 15 | * **First** argument is **greater than** the **second** argument. 16 | */ 17 | After = 1, 18 | Greater = After, 19 | } 20 | -------------------------------------------------------------------------------- /src/libraries/types/codepoint/character.ts: -------------------------------------------------------------------------------- 1 | import {UnicodeCodepoint} from "./unicode"; 2 | 3 | 4 | 5 | import {ParsedFavoriteInfo} from "../savedata/favoriteInfo"; 6 | import {UsageInfo} from "../savedata/usageInfo"; 7 | 8 | /** 9 | * Base representation of a character 10 | */ 11 | export type Character = UnicodeCodepoint; 12 | 13 | export type UsedCharacter = Character & UsageInfo; 14 | export type MaybeUsedCharacter = Character | UsedCharacter; 15 | 16 | export type FavoriteCharacter = Character & ParsedFavoriteInfo; 17 | export type MaybeFavoriteCharacter = Character | FavoriteCharacter; 18 | 19 | /** 20 | * Character with attached metadata for use throughout the plugin 21 | */ 22 | export type MetadataCharacter = Character | UsedCharacter | FavoriteCharacter; 23 | 24 | export type CharacterKey = Character["codepoint"]; 25 | -------------------------------------------------------------------------------- /src/libraries/types/codepoint/codepointInterval.ts: -------------------------------------------------------------------------------- 1 | import {Codepoint} from "./unicode"; 2 | 3 | /** 4 | * Represents a closed interval/range of Unicode Code Points 5 | */ 6 | export interface CodepointInterval { 7 | start: Codepoint, 8 | end: Codepoint, 9 | } 10 | -------------------------------------------------------------------------------- /src/libraries/types/codepoint/extension.ts: -------------------------------------------------------------------------------- 1 | import {FavoriteInfo as RawFavoriteInfo, ParsedFavoriteInfo as FavoriteInfo} from "../savedata/favoriteInfo"; 2 | import {UsageInfo as UseInfo, RawUsageInfo as RawUseInfo} from "../savedata/usageInfo"; 3 | import {CodepointKey} from "./unicode"; 4 | 5 | /** 6 | * Usage information of a specific codepoint as stored in save data 7 | * @see {@link CodepointUse} for parsed version 8 | */ 9 | export type RawCodepointUse = CodepointKey & RawUseInfo; 10 | 11 | /** 12 | * Usage information of a specific codepoint, parsed for use in the plugin 13 | * @see {@link RawCodepointUse} for raw version 14 | */ 15 | export type CodepointUse = CodepointKey & UseInfo; 16 | 17 | /** 18 | * Favorite infor of a specific codepoint as stored in save data 19 | * @see {@link CodepointFavorite} for parsed version 20 | */ 21 | export type RawCodepointFavorite = CodepointKey & RawFavoriteInfo; 22 | 23 | /** 24 | * Favorite information of a specific codepoint, parsed for use in the plugin 25 | * @see {@link RawCodepointFavorite} for raw version 26 | */ 27 | export type CodepointFavorite = CodepointKey & FavoriteInfo; 28 | -------------------------------------------------------------------------------- /src/libraries/types/codepoint/unicode.ts: -------------------------------------------------------------------------------- 1 | export type Char = string 2 | export type Codepoint = number; 3 | 4 | /** 5 | * Universally used key for a Unicode codepoint 6 | */ 7 | export interface CodepointKey { 8 | /** 9 | * A single character defined by a Unicode code point 10 | * @maxLength 1 11 | * @minLength 1 12 | */ 13 | codepoint: Char; 14 | } 15 | 16 | /** 17 | * General attributes of a Unicode codepoint 18 | */ 19 | export interface CodepointAttribute { 20 | /** 21 | * Unicode provided description of the character 22 | */ 23 | name: string; 24 | 25 | /** 26 | * Unicode category of the character 27 | */ 28 | category: string; 29 | } 30 | 31 | /** 32 | * Unicode codepoint representation throughout the plugin 33 | */ 34 | export type UnicodeCodepoint = CodepointKey & CodepointAttribute; 35 | -------------------------------------------------------------------------------- /src/libraries/types/maybe.ts: -------------------------------------------------------------------------------- 1 | export type Maybe = T | null | undefined; 2 | -------------------------------------------------------------------------------- /src/libraries/types/persistCache.ts: -------------------------------------------------------------------------------- 1 | import {UnicodeSearchError} from "../../unicode-search/errors/unicodeSearchError"; 2 | 3 | export class PersistCache { 4 | private value?: T; 5 | 6 | constructor( 7 | private readonly getCallback: () => Promise, 8 | private readonly persistCallback: (value: T) => Promise, 9 | initialValue?: T 10 | ) { 11 | this.value = initialValue; 12 | } 13 | 14 | async get(): Promise { 15 | if (this.value == null) { 16 | this.value = await this.getCallback(); 17 | } 18 | 19 | return this.value; 20 | } 21 | 22 | set(value: T) { 23 | this.value = value 24 | } 25 | 26 | async persist(): Promise { 27 | if (this.value == null) { 28 | throw new UnicodeSearchError("Refuse to persist a null value"); 29 | } 30 | 31 | await this.persistCallback(this.value); 32 | return this.value; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/libraries/types/readCache.ts: -------------------------------------------------------------------------------- 1 | export class ReadCache { 2 | private value?: T; 3 | 4 | constructor( 5 | private readonly getCallback: () => Promise, 6 | initialValue?: T 7 | ) { 8 | this.value = initialValue; 9 | } 10 | 11 | async get(): Promise { 12 | if (this.value == null) { 13 | this.value = await this.getCallback(); 14 | } 15 | 16 | return this.value; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/libraries/types/savedata/dataFragment.ts: -------------------------------------------------------------------------------- 1 | import {SaveDataVersion} from "./version"; 2 | 3 | /** 4 | * Top level fragment of self-standing save data 5 | */ 6 | export interface DataFragment { 7 | initialized: boolean; 8 | version: SaveDataVersion; 9 | } 10 | -------------------------------------------------------------------------------- /src/libraries/types/savedata/favoriteInfo.ts: -------------------------------------------------------------------------------- 1 | import {DateString} from "./usageInfo"; 2 | 3 | /** 4 | * Raw favorite information, as stored in save data 5 | */ 6 | export interface FavoriteInfo { 7 | /** 8 | * Date when the codepoint was added to favorites 9 | */ 10 | added: DateString; 11 | 12 | /** 13 | * Whether the codepoint has a hotkey command 14 | */ 15 | hotkey: boolean; 16 | } 17 | 18 | /** 19 | * Parsed favorite information for use in the plugin 20 | */ 21 | export interface ParsedFavoriteInfo { 22 | /** 23 | * Date when the codepoint was added to favorites 24 | */ 25 | added: Date; 26 | 27 | /** 28 | * Whether the codepoint has a hotkey command 29 | */ 30 | hotkey: boolean; 31 | } 32 | -------------------------------------------------------------------------------- /src/libraries/types/savedata/favoritesFragment.ts: -------------------------------------------------------------------------------- 1 | import {DataFragment} from "./dataFragment"; 2 | import {RawCodepointFavorite} from "../codepoint/extension"; 3 | 4 | /** 5 | * Users favorite codepoints 6 | */ 7 | export interface FavoritesFragment extends DataFragment { 8 | /** 9 | * List of favorite codepoints 10 | */ 11 | codepoints: RawCodepointFavorite[] 12 | } 13 | -------------------------------------------------------------------------------- /src/libraries/types/savedata/filterFragment.ts: -------------------------------------------------------------------------------- 1 | import {DataFragment} from "./dataFragment"; 2 | import {UnicodeFilter} from "./unicodeFilter"; 3 | 4 | /** 5 | * User saved character filters 6 | */ 7 | export interface FilterFragment extends DataFragment { 8 | /** 9 | * Filter criteria for Unicode characters 10 | */ 11 | unicode: UnicodeFilter; 12 | } 13 | -------------------------------------------------------------------------------- /src/libraries/types/savedata/initialSaveData.ts: -------------------------------------------------------------------------------- 1 | import {SaveDataVersion} from "./version"; 2 | 3 | export interface InitialSaveData { 4 | initialized: boolean; 5 | version: SaveDataVersion & ( "0.4.0" | "0.5.0" | "0.6.0" ); 6 | 7 | settings: { 8 | initialized: boolean; 9 | modified: boolean; 10 | filter: { 11 | planes: Array<{ 12 | start: number 13 | end: number 14 | blocks: Array<{ 15 | start: number 16 | end: number 17 | included: boolean 18 | }> 19 | }>; 20 | categoryGroups: Array<{ 21 | abbreviation: "L" | "M" | "N" | "P" | "S" | "Z" | "C"; 22 | categories: Array<{ 23 | abbreviation: "Ll" | "Lm" | "Lt" | "Lu" | "Lo" 24 | | "Mc" | "Me" | "Mn" 25 | | "Nd" | "Nl" | "No" 26 | | "Pc" | "Pd" | "Pi" | "Pf" | "Ps" | "Pe" | "Po" 27 | | "Sc" | "Sk" | "Sm" | "So" 28 | | "Zl" | "Zp" | "Zs" 29 | | "Cc" | "Cf" | "Cn" | "Co" | "Cs"; 30 | included: boolean; 31 | }>; 32 | }>; 33 | }; 34 | }; 35 | unicode: { 36 | initialized: boolean; 37 | codepoints: Array<{ 38 | codepoint: string; 39 | name: string; 40 | category: string; 41 | }> 42 | }; 43 | usage: { 44 | initialized: boolean; 45 | codepoints: Array<{ 46 | codepoint: string 47 | firstUsed: string; 48 | lastUsed: string; 49 | useCount: number; 50 | }> 51 | }; 52 | } 53 | 54 | export function isInitialSaveData(object: Partial): object is InitialSaveData { 55 | return object != null 56 | && "initialized" in object 57 | && "version" in object 58 | 59 | && "settings" in object 60 | && object.settings != null 61 | && "initialized" in object.settings 62 | && "modified" in object.settings 63 | && "filter" in object.settings 64 | 65 | && "unicode" in object 66 | && object.unicode != null 67 | && "initialized" in object.unicode 68 | && "codepoints" in object.unicode 69 | 70 | && "usage" in object 71 | && object.usage != null 72 | && "initialized" in object.usage 73 | && "codepoints" in object.usage 74 | ; 75 | } 76 | -------------------------------------------------------------------------------- /src/libraries/types/savedata/metaFragment.ts: -------------------------------------------------------------------------------- 1 | import {DataFragment} from "./dataFragment"; 2 | import { PluginVersion } from "./version"; 3 | 4 | /** 5 | * Meta information for the datastore 6 | */ 7 | export interface MetaFragment extends DataFragment { 8 | /** 9 | * Latest used version of the plugin which used this data 10 | */ 11 | pluginVersion: PluginVersion; 12 | 13 | /** 14 | * A collection of unique data events. 15 | */ 16 | events: DataEvent[]; 17 | } 18 | 19 | /** 20 | * An event, which communicates information between data parts 21 | */ 22 | export enum DataEvent { 23 | DownloadCharacters = "download_characters", 24 | } 25 | 26 | /** 27 | * Checks if the given object is a valid DataEvent. 28 | * @param object The object to check. 29 | * @returns True if the object is a valid DataEvent, false otherwise. 30 | */ 31 | export function isDataEvent(object: any): object is DataEvent { 32 | return Object.values(DataEvent).includes(object); 33 | } 34 | -------------------------------------------------------------------------------- /src/libraries/types/savedata/saveData.ts: -------------------------------------------------------------------------------- 1 | import {FilterFragment} from "./filterFragment"; 2 | import {UnicodeFragment} from "./unicodeFragment"; 3 | import {CharacterUseFragment as UsageFragment} from "./usageFragment"; 4 | import {FavoritesFragment} from "./favoritesFragment"; 5 | import {MetaFragment} from "./metaFragment"; 6 | import { DataFragment } from "./dataFragment"; 7 | 8 | /** 9 | * Generic structure of `data.json` 10 | */ 11 | export interface SaveDataOf { 12 | /** 13 | * Metadata information about the save data itself 14 | */ 15 | meta: T; 16 | 17 | /** 18 | * Filtering of downloaded/displayed codepoints 19 | */ 20 | filter: T; 21 | 22 | /** 23 | * Local character database 24 | */ 25 | characters: T; 26 | 27 | /** 28 | * Usage information generated by the user 29 | */ 30 | usage: T; 31 | 32 | /** 33 | * Favorites saved manually by the user 34 | */ 35 | favorites: T; 36 | } 37 | 38 | /** 39 | * Structure of `data.json`, where each fragment is a self-managed data fragment 40 | */ 41 | export interface SaveData extends SaveDataOf { 42 | meta: MetaFragment; 43 | filter: FilterFragment; 44 | characters: UnicodeFragment; 45 | usage: UsageFragment; 46 | favorites: FavoritesFragment; 47 | } 48 | -------------------------------------------------------------------------------- /src/libraries/types/savedata/unicodeFilter.ts: -------------------------------------------------------------------------------- 1 | import {CodepointInterval} from "../codepoint/codepointInterval"; 2 | import {CharacterCategoryType} from "../../data/characterCategory"; 3 | import {CharacterCategoryGroupType} from "../../data/characterCategoryGroup"; 4 | 5 | /** 6 | * User set filter data for Unicode characters. 7 | */ 8 | export interface UnicodeFilter { 9 | /** 10 | * Filter criteria for planes of Unicode characters 11 | */ 12 | planes: PlaneFilter[]; 13 | 14 | /** 15 | * Filter criteria for category groups of Unicode characters 16 | */ 17 | categoryGroups: CategoryGroupFilter[]; 18 | } 19 | 20 | /** 21 | * Unicode Plane of Unicode Blocks 22 | */ 23 | export interface PlaneFilter extends CodepointInterval { 24 | /** 25 | * Filter criteria for blocks of Unicode characters 26 | */ 27 | blocks: BlockFilter[]; 28 | } 29 | 30 | /** 31 | * A flag indicating whether a character is included in search or not 32 | */ 33 | export interface InclusionFlag { 34 | /** 35 | * Indicates whether a character is included in search or not 36 | */ 37 | included: boolean; 38 | } 39 | 40 | /** 41 | * Block of Unicode Codepoints 42 | */ 43 | export type BlockFilter = CodepointInterval & InclusionFlag; 44 | 45 | /** 46 | * Filters for a group of categories. 47 | */ 48 | export interface CategoryGroupFilter { 49 | /** 50 | * Single letter abbreviation of the category group. 51 | * @maxLength 1 52 | * @minLength 1 53 | */ 54 | abbreviation: CharacterCategoryGroupType; 55 | 56 | /** 57 | * Categories which belong to the group. 58 | */ 59 | categories: CategoryFilter[]; 60 | } 61 | 62 | /** 63 | * Filter for a single category. 64 | */ 65 | export interface CategoryFilter extends InclusionFlag { 66 | /** 67 | * Two letter abbreviation of the category. 68 | * @maxLength 2 69 | * @minLength 2 70 | */ 71 | abbreviation: CharacterCategoryType; 72 | } 73 | -------------------------------------------------------------------------------- /src/libraries/types/savedata/unicodeFragment.ts: -------------------------------------------------------------------------------- 1 | import {DataFragment} from "./dataFragment"; 2 | import {UnicodeCodepoint} from "../codepoint/unicode"; 3 | 4 | /** 5 | * Downloaded Unicode Character Database 6 | */ 7 | export interface UnicodeFragment extends DataFragment { 8 | /** 9 | * Codepoints downloaded from the Unicode Character Database 10 | */ 11 | codepoints: UnicodeCodepoint[] 12 | } 13 | -------------------------------------------------------------------------------- /src/libraries/types/savedata/usageFragment.ts: -------------------------------------------------------------------------------- 1 | import {DataFragment} from "./dataFragment"; 2 | import {RawCodepointUse} from "../codepoint/extension"; 3 | 4 | /** 5 | * User generated usage data 6 | */ 7 | export interface CharacterUseFragment extends DataFragment { 8 | /** 9 | * Statistics of the individual codepoint usage 10 | */ 11 | codepoints: RawCodepointUse[] 12 | } 13 | -------------------------------------------------------------------------------- /src/libraries/types/savedata/usageInfo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Alias to hint that a string is a date string 3 | * @example "2023-10-01T12:00:00Z" 4 | */ 5 | export type DateString = string; 6 | 7 | /** 8 | * Raw usage information, as stored in save data 9 | */ 10 | export interface RawUsageInfo { 11 | firstUsed: DateString; 12 | lastUsed: DateString; 13 | useCount: number; 14 | } 15 | 16 | /** 17 | * Parsed usage information for use in the plugin 18 | */ 19 | export type UsageInfo = UsageCount & UsageDate; 20 | 21 | /** 22 | * Usage count for statistics 23 | */ 24 | export interface UsageCount { 25 | useCount: number; 26 | } 27 | 28 | /** 29 | * Usage date for statistics 30 | */ 31 | export interface UsageDate { 32 | firstUsed: Date; 33 | lastUsed: Date; 34 | } 35 | -------------------------------------------------------------------------------- /src/libraries/types/savedata/version.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Version of the plugin. 3 | * 4 | * Must comply with RegEx: 5 | * ```^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[A-Z]+)?$``` 6 | */ 7 | export type PluginVersion 8 | = "0.2.0" 9 | | "0.2.1" 10 | | "0.2.2" 11 | | "0.2.3" 12 | | "0.3.0" 13 | | "0.4.0" 14 | | "0.4.1" 15 | | "0.5.0" 16 | | "0.6.0" 17 | | "0.6.1" 18 | | "0.7.0" 19 | | "0.7.1" 20 | // Update every release 21 | ; 22 | 23 | export type CurrentPluginVersion = "0.7.1" & PluginVersion; 24 | export const CURRENT_PLUGIN_VERSION: CurrentPluginVersion = "0.7.1"; 25 | 26 | /** 27 | * Version of the save data schema. 28 | * 29 | * Must comply with RegEx: 30 | * ```^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[A-Z]+)?$``` 31 | * 32 | * The version of the save data schema is independent of the plugin version 33 | * @see {@link PluginVersion} 34 | */ 35 | export type SaveDataVersion = PluginVersion & 36 | ( "0.4.0" 37 | | "0.5.0" 38 | | "0.6.0" 39 | | "0.7.0" 40 | // Update only if save data schema changed 41 | ); 42 | 43 | export type CurrentSaveDataVersion = "0.7.0" & SaveDataVersion; 44 | export const CURRENT_DATA_VERSION: SaveDataVersion = "0.7.0"; 45 | -------------------------------------------------------------------------------- /src/libraries/types/unicode/unicodeBlock.ts: -------------------------------------------------------------------------------- 1 | import {UnicodePlaneNumber} from "../../data/unicodePlaneNumber"; 2 | 3 | import {CodepointInterval} from "../codepoint/codepointInterval"; 4 | 5 | export interface UnicodeBlock { 6 | interval: CodepointInterval, 7 | description: string, 8 | plane: UnicodePlaneNumber 9 | } 10 | -------------------------------------------------------------------------------- /src/libraries/types/unicode/unicodeGeneralCategory.ts: -------------------------------------------------------------------------------- 1 | import {CharacterCategoryType} from "../../data/characterCategory"; 2 | 3 | export interface UnicodeGeneralCategory { 4 | abbreviation: CharacterCategoryType; 5 | name: string; 6 | description: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/libraries/types/unicode/unicodeGeneralCategoryGroup.ts: -------------------------------------------------------------------------------- 1 | import {CharacterCategoryGroupType} from "../../data/characterCategoryGroup"; 2 | import {UnicodeGeneralCategory} from "./unicodeGeneralCategory"; 3 | 4 | export interface UnicodeGeneralCategoryGroup { 5 | abbreviation: CharacterCategoryGroupType; 6 | name: string; 7 | categories: UnicodeGeneralCategory[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/libraries/types/unicode/unicodePlane.ts: -------------------------------------------------------------------------------- 1 | import {UnicodeBlock} from "./unicodeBlock"; 2 | import {UnicodePlaneNumber} from "../../data/unicodePlaneNumber"; 3 | import {CodepointInterval} from "../codepoint/codepointInterval"; 4 | 5 | export interface UnicodePlane { 6 | planeNumber: UnicodePlaneNumber, 7 | description: string, 8 | abbreviation: string, 9 | interval: CodepointInterval, 10 | blocks: UnicodeBlock[], 11 | } 12 | -------------------------------------------------------------------------------- /src/libraries/types/usageDisplayStatistics.ts: -------------------------------------------------------------------------------- 1 | export interface UsageDisplayStatistics { 2 | topThirdRecentlyUsed: Date; 3 | averageUseCount: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/unicode-search/components/characterSearch.ts: -------------------------------------------------------------------------------- 1 | import {SearchResult} from "obsidian"; 2 | import {Character, MetadataCharacter} from "../../libraries/types/codepoint/character"; 3 | import {CharacterSearchAttributes} from "./characterSearchAttributes"; 4 | import {Maybe} from "../../libraries/types/maybe"; 5 | 6 | /** 7 | * Evaluation of the strength of a match from a search 8 | */ 9 | export type SearchMatchResult = SearchResult; 10 | 11 | export const NONE_RESULT: SearchMatchResult = { 12 | score: 0, 13 | matches: [] 14 | } 15 | 16 | /** 17 | * Search result of a single character match 18 | */ 19 | export type CharacterSearchResult = { 20 | character: Character & CharacterType, 21 | match: CharacterSearchAttributes, 22 | } 23 | 24 | export type MetaCharacterSearchResult = CharacterSearchResult; 25 | export type MaybeMetaCharacterSearchResult = CharacterSearchResult>; 26 | export type MaybeSearchMatchAttributes = CharacterSearchAttributes>; 27 | export type SearchMatchAttributes = CharacterSearchAttributes; 28 | -------------------------------------------------------------------------------- /src/unicode-search/components/characterSearchAttributes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Attributes which are used for searching characters 3 | */ 4 | export type CharacterSearchAttributes = { 5 | 6 | /** 7 | * Literal value identifying the codepoint 8 | * 9 | * @see Codepoint 10 | */ 11 | codepoint: T, 12 | 13 | 14 | /** 15 | * The main name representing the character 16 | * 17 | * @see CodepointAttribute.name 18 | */ 19 | name: T, 20 | } 21 | -------------------------------------------------------------------------------- /src/unicode-search/components/fuzzySearchModal.ts: -------------------------------------------------------------------------------- 1 | import {App, Instruction, renderMatches, SuggestModal} from "obsidian"; 2 | import {MetaCharacterSearchResult} from "./characterSearch"; 3 | import {CharacterService} from "../service/characterService"; 4 | import { 5 | ELEMENT_FAVORITE, 6 | ELEMENT_FREQUENT, 7 | ELEMENT_RECENT, 8 | INSTRUCTION_DISMISS, 9 | NAVIGATE_INSTRUCTION 10 | } from "./visualElements"; 11 | import {toHexadecimal} from "../../libraries/helpers/toHexadecimal"; 12 | import {getRandomItem} from "../../libraries/helpers/getRandomItem"; 13 | import {fillNullCharacterMatchScores} from "../../libraries/comparison/fillNullCharacterMatchScores"; 14 | import {compareCharacterMatches} from "../../libraries/comparison/compareCharacterMatches"; 15 | import {ReadCache} from "../../libraries/types/readCache"; 16 | import {mostRecentUses} from "../../libraries/helpers/mostRecentUses"; 17 | import {averageUseCount} from "../../libraries/helpers/averageUseCount"; 18 | import {UsageDisplayStatistics} from "../../libraries/types/usageDisplayStatistics"; 19 | import {toNullMatch} from "../../libraries/helpers/toNullMatch"; 20 | import {toSearchQueryMatch} from "../../libraries/helpers/toSearchQueryMatch"; 21 | import {matchedNameOrCodepoint} from "../../libraries/helpers/matchedNameOrCodepoint"; 22 | 23 | import {isFavoriteCharacter} from "../../libraries/helpers/isFavoriteCharacter"; 24 | import {UsageInfo} from "../../libraries/types/savedata/usageInfo"; 25 | 26 | export abstract class FuzzySearchModal extends SuggestModal { 27 | /* TODO [non-func]: Extract the functionalities needed for inserting/picking characters 28 | * Picking characters needs to have a filter for chars too. 29 | * The inheritance used here is very messy, use composition instead. 30 | */ 31 | 32 | private readonly usageStatistics: ReadCache; 33 | 34 | protected constructor( 35 | app: App, 36 | protected readonly characterService: CharacterService, 37 | chooseCharacter: Instruction, 38 | 39 | ) { 40 | super(app); 41 | 42 | super.setInstructions([ 43 | NAVIGATE_INSTRUCTION, 44 | chooseCharacter, 45 | INSTRUCTION_DISMISS, 46 | ]); 47 | 48 | // Purposefully ignored result 49 | this.setRandomPlaceholder().then(); 50 | 51 | this.usageStatistics = new ReadCache(async () => { 52 | const usedCharacters = await characterService.getUsed(); 53 | return { 54 | topThirdRecentlyUsed: mostRecentUses(usedCharacters).slice(0, 3).last() ?? new Date(0), 55 | averageUseCount: averageUseCount(usedCharacters), 56 | } as UsageDisplayStatistics; 57 | }); 58 | } 59 | 60 | public override async getSuggestions(query: string): Promise { 61 | const allCharacters = (await this.characterService.getAll()); 62 | const queryEmpty = query == null || query.length < 1; 63 | 64 | const prepared = queryEmpty 65 | ? allCharacters 66 | .map(toNullMatch) 67 | : allCharacters 68 | .map(toSearchQueryMatch(query)) 69 | .filter(matchedNameOrCodepoint); 70 | 71 | const recencyCutoff = (await this.usageStatistics.get()).topThirdRecentlyUsed; 72 | 73 | return prepared 74 | .sort((l, r) => compareCharacterMatches(l, r, recencyCutoff)) 75 | .slice(0, this.limit) 76 | .map(fillNullCharacterMatchScores); 77 | } 78 | 79 | public override async renderSuggestion(search: MetaCharacterSearchResult, container: HTMLElement): Promise { 80 | const char = search.character; 81 | 82 | container.addClass("plugin", "unicode-search", "result-item"); 83 | 84 | container.createDiv({ 85 | cls: "character-preview", 86 | }).createSpan({ 87 | text: char.codepoint, 88 | }); 89 | 90 | const matches = container.createDiv({ 91 | cls: "character-match", 92 | }); 93 | 94 | const text = matches.createDiv({ 95 | cls: "character-name", 96 | }); 97 | 98 | renderMatches(text, char.name, search.match.name.matches); 99 | 100 | /* TODO [ui][?]: We can show the character category in search results */ 101 | /* const category = matches.createDiv({ 102 | cls: "character-category", 103 | }).createSpan({ 104 | text: char.category, 105 | }); */ 106 | 107 | const codepoint = matches.createDiv({ 108 | cls: "character-codepoint", 109 | }); 110 | 111 | renderMatches(codepoint, toHexadecimal(char), search.match.codepoint.matches); 112 | 113 | const detail = container.createDiv({ 114 | cls: "detail", 115 | }); 116 | 117 | const attributes = detail.createDiv({ 118 | cls: "attributes", 119 | }); 120 | 121 | if (isFavoriteCharacter(char)) { 122 | attributes.createDiv(ELEMENT_FAVORITE); 123 | } else { 124 | const usageStats = await this.usageStatistics.get(); 125 | 126 | /* The type hinting doesn't work, and shows as an error in the IDE (or the type is wrong) */ 127 | const maybeUsedChar = char as Partial 128 | const showLastUsed = maybeUsedChar.lastUsed != null && maybeUsedChar.lastUsed >= usageStats.topThirdRecentlyUsed; 129 | const showUseCount = maybeUsedChar.useCount != null && maybeUsedChar.useCount >= usageStats.averageUseCount; 130 | 131 | if (showLastUsed) { 132 | attributes.createDiv(ELEMENT_RECENT); 133 | } 134 | 135 | if (showUseCount) { 136 | attributes.createDiv(ELEMENT_FREQUENT); 137 | } 138 | } 139 | } 140 | 141 | public override async onNoSuggestion(): Promise { 142 | await this.setRandomPlaceholder(); 143 | } 144 | 145 | private async setRandomPlaceholder(): Promise { 146 | const randomCharacterName = getRandomItem(await this.characterService.getAllCharacters()).name; 147 | super.setPlaceholder(`Unicode search: ${randomCharacterName}`); 148 | } 149 | 150 | } 151 | -------------------------------------------------------------------------------- /src/unicode-search/components/insertCharacterModal.ts: -------------------------------------------------------------------------------- 1 | import {App, Editor} from "obsidian"; 2 | import {MetaCharacterSearchResult} from "./characterSearch"; 3 | import {CharacterService} from "../service/characterService"; 4 | import {INSERT_CHAR_INSTRUCTION} from "./visualElements"; 5 | import {FuzzySearchModal} from "./fuzzySearchModal"; 6 | 7 | export class InsertCharacterModal extends FuzzySearchModal { 8 | public constructor( 9 | app: App, 10 | characterService: CharacterService, 11 | private readonly editor: Editor, 12 | ) { 13 | super(app, characterService, INSERT_CHAR_INSTRUCTION); 14 | } 15 | 16 | public override async onChooseSuggestion(search: MetaCharacterSearchResult, _: MouseEvent | KeyboardEvent): Promise { 17 | this.editor.replaceSelection(search.character.codepoint); 18 | 19 | try { 20 | /* super.characterService here throws an undefined exception (super is undefined) */ 21 | await this.characterService.recordUsage(search.character.codepoint); 22 | } catch (error) { 23 | console.error("Failed to record character usage", {err: error}); 24 | } 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/unicode-search/components/pickCharacterModal.ts: -------------------------------------------------------------------------------- 1 | import {FuzzySearchModal} from "./fuzzySearchModal"; 2 | import {App} from "obsidian"; 3 | import {CharacterService} from "../service/characterService"; 4 | import {Character} from "../../libraries/types/codepoint/character"; 5 | import {INSERT_CHAR_INSTRUCTION} from "./visualElements"; 6 | import {MetaCharacterSearchResult} from "./characterSearch"; 7 | import {Maybe} from "../../libraries/types/maybe"; 8 | 9 | export class PickCharacterModal extends FuzzySearchModal { 10 | private constructor( 11 | app: App, 12 | characterService: CharacterService, 13 | private readonly resolve: (value: (PromiseLike> | Maybe)) => void, 14 | ) { 15 | super(app, characterService, INSERT_CHAR_INSTRUCTION); 16 | } 17 | 18 | /** 19 | * Open the modal and resolve the promise with the chosen character 20 | */ 21 | static open(app: App, characterService: CharacterService): Promise> { 22 | return new Promise>(resolve => { 23 | const modal = new PickCharacterModal(app, characterService, resolve); 24 | modal.open(); 25 | }) 26 | } 27 | 28 | override onChooseSuggestion(_: MetaCharacterSearchResult, __: MouseEvent | KeyboardEvent) { 29 | /* 30 | * Intentionally left blank 31 | * The `onClose` fires before `onChooseSuggestion`, so we have to override the `selectSuggestion` which calls both of them 32 | */ 33 | } 34 | 35 | override onClose() { 36 | this.resolve(null) 37 | } 38 | 39 | override selectSuggestion(search: MetaCharacterSearchResult, evt: MouseEvent | KeyboardEvent) { 40 | this.resolve(search.character); 41 | super.selectSuggestion(search, evt); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/unicode-search/components/settingTab.ts: -------------------------------------------------------------------------------- 1 | import {App, Plugin, PluginSettingTab, Setting} from "obsidian"; 2 | import {UNICODE_PLANES_ALL} from "../../libraries/data/unicodePlanes"; 3 | import {UnicodeBlock} from "../../libraries/types/unicode/unicodeBlock"; 4 | 5 | import {asHexadecimal} from "../../libraries/helpers/asHexadecimal"; 6 | import {CharacterService} from "../service/characterService"; 7 | import {FilterStore} from "../service/filterStore"; 8 | import {CodepointInterval} from "../../libraries/types/codepoint/codepointInterval"; 9 | import {UnicodePlane} from "../../libraries/types/unicode/unicodePlane"; 10 | import {UNICODE_CHARACTER_CATEGORIES} from "../../libraries/data/unicodeCharacterCategories"; 11 | import {UnicodeGeneralCategoryGroup} from "../../libraries/types/unicode/unicodeGeneralCategoryGroup"; 12 | import {UnicodeGeneralCategory} from "../../libraries/types/unicode/unicodeGeneralCategory"; 13 | import {DataManager} from "../service/dataManager"; 14 | import {FavoritesStore} from "../service/favoritesStore"; 15 | import {toHexadecimal} from "../../libraries/helpers/toHexadecimal"; 16 | import {Character, FavoriteCharacter} from "../../libraries/types/codepoint/character"; 17 | import {PickCharacterModal} from "./pickCharacterModal"; 18 | 19 | export class SettingTab extends PluginSettingTab { 20 | /* TODO [non-func]: Make settings code easier to comprehend 21 | * Try using svelte for nicer UI component code. 22 | * Also, the naming is confusing. 23 | * Don't forget about the CSS styles too. 24 | */ 25 | 26 | private rendered = false; 27 | 28 | constructor( 29 | app: App, 30 | private readonly plugin: Plugin, 31 | private readonly characterService: CharacterService, 32 | private readonly favoritesStore: FavoritesStore, 33 | private readonly settingsStore: FilterStore, 34 | private readonly initializer: DataManager, 35 | ) { 36 | super(app, plugin); 37 | this.containerEl.addClass("plugin", "unicode-search", "setting-tab"); 38 | } 39 | 40 | override async display(): Promise { 41 | if (this.rendered) { 42 | return; 43 | } 44 | 45 | await this.displayFilterSettings(this.containerEl); 46 | await this.displayFavoritesSettings(this.containerEl); 47 | 48 | this.rendered = true; 49 | } 50 | 51 | override async hide(): Promise { 52 | await this.initializer.initializeData(); 53 | this.containerEl.empty(); 54 | this.rendered = false; 55 | } 56 | 57 | private async displayFavoritesSettings(container: HTMLElement) { 58 | new Setting(container) 59 | .setHeading() 60 | .setName("Favorite Characters") 61 | .setDesc( 62 | "Manage your favorite characters which will be displayed in the plugin's search results. " + 63 | "You can also enable them as a hotkey, making them available as a command in Obsidian." 64 | ) 65 | .setClass("group-control") 66 | .addToggle(toggle => toggle 67 | .setValue(false) 68 | .onChange(visible => manageFavoritesContainer.toggleClass("hidden", !visible)) 69 | ) 70 | ; 71 | 72 | const favorites = await this.characterService.getFavorites(); 73 | 74 | const manageFavoritesContainer = container.createDiv({cls: ["group-container", "hidden"]}); 75 | const itemContainer = manageFavoritesContainer.createDiv({cls: ["item-container"]}); 76 | const newCharacterList = itemContainer.createDiv({cls: ["character-list", "new"]}); 77 | const characterList = itemContainer.createDiv({cls: ["character-list", "no-first"]}); 78 | 79 | new Setting(newCharacterList) 80 | .setName("") 81 | .setDesc("Add a new favorite character") 82 | .addButton(btn => { 83 | btn.setIcon("plus") 84 | btn.onClick(async _ => { 85 | const char = await PickCharacterModal.open(this.plugin.app, this.characterService); 86 | 87 | if (char == null) { 88 | return; 89 | } 90 | 91 | const isAlreadyFavorite = favorites.some(fav => fav.codepoint === char.codepoint); 92 | if (isAlreadyFavorite) { 93 | return; 94 | } 95 | 96 | const favorite = await this.favoritesStore.addFavorite(char.codepoint); 97 | const favoriteChar = {...favorite, ...char}; 98 | this.displayFavoriteChar(newCharacterList, favoriteChar) 99 | }) 100 | }) 101 | ; 102 | 103 | for (const character of favorites) { 104 | this.displayFavoriteChar(characterList, character); 105 | } 106 | } 107 | 108 | private displayFavoriteChar(container: HTMLElement, character: FavoriteCharacter) { 109 | const setting = new Setting(container); 110 | 111 | setting 112 | .setClass("favorite-control") 113 | .setName(character.codepoint) 114 | .setDesc(character.name) 115 | .addToggle(toggle => toggle 116 | .setTooltip("Add insert command to Obsidian") 117 | .setValue(character.hotkey) 118 | .onChange(enabled => this.toggleHotkeyCommand(character, enabled)) 119 | ) 120 | .addButton(button => button 121 | .setIcon("trash") 122 | .setTooltip("Remove from favorites") 123 | .onClick(() => { 124 | setting.settingEl.hide() 125 | return this.favoritesStore.removeFavorite(character.codepoint); 126 | }) 127 | ) 128 | ; 129 | } 130 | 131 | private async toggleHotkeyCommand(character: Character, enabled: boolean): Promise { 132 | const insertCharId = `insert-${toHexadecimal(character)}`; 133 | 134 | if (enabled) { 135 | this.plugin.addCommand({ 136 | id: insertCharId, 137 | name: `Insert '${character.codepoint}'`, 138 | editorCallback: editor => { 139 | editor.replaceSelection(character.codepoint); 140 | return true; 141 | }, 142 | }) 143 | } else { 144 | this.plugin.removeCommand(insertCharId); 145 | } 146 | 147 | await this.favoritesStore.update(character.codepoint, () => ({hotkey: enabled})); 148 | } 149 | 150 | private async displayFilterSettings(container: HTMLElement) { 151 | new Setting(container) 152 | .setHeading() 153 | .setName("Unicode Character Filters") 154 | .setDesc( 155 | "Here you can set which characters would you like to be included " + 156 | "or excluded from the plugins search results. " + 157 | "Toggle the headings to display the options." 158 | ) 159 | ; 160 | 161 | new Setting(container) 162 | .setName("General Categories") 163 | .setClass("group-control") 164 | .addToggle(toggle => toggle 165 | .setValue(false) 166 | .onChange(visible => categoryFilterDiv.toggleClass("hidden", !visible)) 167 | ) 168 | ; 169 | 170 | const categoryFilterDiv = container.createDiv({cls: ["group-container", "hidden"]}); 171 | 172 | for (const category of UNICODE_CHARACTER_CATEGORIES) { 173 | await this.addCharacterCategoryFilter(categoryFilterDiv, category); 174 | } 175 | 176 | new Setting(container) 177 | .setName("Planes and Blocks") 178 | .setClass("group-control") 179 | .addToggle(toggle => toggle 180 | .setValue(false) 181 | .onChange(visible => planesFilterDiv.toggleClass("hidden", !visible)) 182 | ) 183 | ; 184 | 185 | const planesFilterDiv = container.createDiv({cls: ["group-container", "hidden"]}); 186 | 187 | for (const plane of UNICODE_PLANES_ALL) { 188 | await this.addCharacterPlaneFilters(planesFilterDiv, plane); 189 | } 190 | } 191 | 192 | private async addCharacterCategoryFilter(container: HTMLElement, categoryGroup: UnicodeGeneralCategoryGroup) { 193 | const categoryGroupContainer = container.createDiv({cls: "item-container"}); 194 | 195 | new Setting(categoryGroupContainer) 196 | .setHeading() 197 | .setName(categoryGroup.name) 198 | ; 199 | 200 | const categoryContainer = categoryGroupContainer.createDiv({cls: "items-list"}); 201 | 202 | for (const category of categoryGroup.categories) { 203 | await SettingTab.addCharacterCategoryFilterToggle(categoryContainer, this.settingsStore, category); 204 | } 205 | } 206 | 207 | private async addCharacterPlaneFilters(container: HTMLElement, plane: UnicodePlane) { 208 | const planeContainer = container.createDiv({cls: "item-container"}); 209 | 210 | new Setting(planeContainer) 211 | .setHeading() 212 | .setClass("codepoint-interval") 213 | .setName(createFragment(fragment => { 214 | fragment.createSpan().appendText(plane.description); 215 | SettingTab.codepointFragment(fragment, plane.interval) 216 | })) 217 | ; 218 | 219 | const blocksContainer = planeContainer.createDiv({cls: "blocks-list"}); 220 | 221 | for (const block of plane.blocks) { 222 | await SettingTab.addCharacterBlockFilterToggle(blocksContainer, this.settingsStore, block); 223 | } 224 | } 225 | 226 | private static async addCharacterBlockFilterToggle( 227 | container: HTMLElement, 228 | options: FilterStore, 229 | block: UnicodeBlock 230 | ) { 231 | /* Low: try to redo more effectively, we always get a plane worth of blocks */ 232 | const blockIncluded = await options.getCharacterBlock(block.interval); 233 | 234 | new Setting(container) 235 | .setName(block.description) 236 | .setDesc(createFragment(fragment => SettingTab.codepointFragment(fragment, block.interval))) 237 | .addToggle(input => input 238 | .setValue(blockIncluded) 239 | .onChange((value) => options.setCharacterBlock(block.interval, value)) 240 | ); 241 | } 242 | 243 | private static async addCharacterCategoryFilterToggle( 244 | container: HTMLElement, 245 | options: FilterStore, 246 | category: UnicodeGeneralCategory 247 | ) { 248 | /* Low: try to redo more effectively, we always get a plane worth of blocks */ 249 | const blockIncluded = await options.getCharacterCategory(category.abbreviation); 250 | 251 | new Setting(container) 252 | .setName(category.name) 253 | .setDesc(category.description) 254 | .addToggle(input => input 255 | .setValue(blockIncluded) 256 | .onChange((value) => options.setCharacterCategory(category.abbreviation, value)) 257 | ); 258 | } 259 | 260 | private static codepointFragment(parent: DocumentFragment, interval: CodepointInterval): DocumentFragment { 261 | parent 262 | .createSpan({cls: ["character-codepoint", "monospace"],}) 263 | .setText(`${asHexadecimal(interval.start)}-${asHexadecimal(interval.end)}`); 264 | 265 | return parent; 266 | } 267 | 268 | } 269 | -------------------------------------------------------------------------------- /src/unicode-search/components/visualElements.ts: -------------------------------------------------------------------------------- 1 | import {Instruction} from "obsidian"; 2 | 3 | export const NAVIGATE_INSTRUCTION: Instruction = { 4 | command: "⮁", 5 | purpose: "to navigate", 6 | }; 7 | 8 | export const INSERT_CHAR_INSTRUCTION: Instruction = { 9 | command: "↵", 10 | purpose: "to insert selected character", 11 | }; 12 | 13 | export const INSTRUCTION_DISMISS: Instruction = { 14 | command: "esc", 15 | purpose: "to dismiss", 16 | }; 17 | 18 | export const ELEMENT_RECENT: DomElementInfo = { 19 | cls: "icon inline-description recent", 20 | text: "↩", 21 | title: "used recently", 22 | }; 23 | 24 | export const ELEMENT_FREQUENT: DomElementInfo = { 25 | cls: "icon inline-description frequent", 26 | text: "↺", 27 | title: "used frequently", 28 | }; 29 | 30 | export const ELEMENT_FAVORITE: DomElementInfo = { 31 | cls: "icon inline-description favorite", 32 | text: "☆", 33 | title: "favorite", 34 | }; 35 | -------------------------------------------------------------------------------- /src/unicode-search/errors/unicodeSearchError.ts: -------------------------------------------------------------------------------- 1 | export class UnicodeSearchError extends Error { 2 | 3 | public constructor(message: string) { 4 | super(message); 5 | } 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/unicode-search/main.ts: -------------------------------------------------------------------------------- 1 | import {App, Plugin, PluginManifest} from "obsidian"; 2 | import {UcdUserFilterDownloader} from "./service/ucdUserFilterDownloader"; 3 | import {SettingTab} from "./components/settingTab"; 4 | import {UserCharacterService} from "./service/userCharacterService"; 5 | 6 | import {Commander} from "./service/commander"; 7 | import {RootDataManager} from "./service/rootDataManager"; 8 | import {FilterDataManager} from "./service/filterDataManager"; 9 | import {UnicodeDataManager} from "./service/unicodeDataManager"; 10 | import {UsageDataManager} from "./service/usageDataManager"; 11 | import {FavoritesDataManager} from "./service/favoritesDataManager"; 12 | import {PersistCache} from "../libraries/types/persistCache"; 13 | import {RootPluginDataStorage} from "./service/rootPluginDataStorage"; 14 | import {CodepointStorage} from "./service/codepointStorage"; 15 | import {CodepointUsageStorage} from "./service/codepointUsageStorage"; 16 | import {CodepointFavoritesStorage} from "./service/codepointFavoritesStorage"; 17 | import {FilterStorage} from "./service/filterStorage"; 18 | import {MetaDataManager} from "./service/metaDataManager"; 19 | import {MetaStorage} from "./service/metaStorage"; 20 | 21 | /* Used by Obsidian */ 22 | // noinspection JSUnusedGlobalSymbols 23 | export default class UnicodeSearchPlugin extends Plugin { 24 | /* TODO [non-func]: Cleanup the codebase -- make it intuitive 25 | * There's a bunch of unnecessary classes and extraneous generalizations. 26 | * Add docs to the necessary parts. 27 | */ 28 | 29 | public constructor( 30 | app: App, 31 | manifest: PluginManifest, 32 | ) { 33 | super(app, manifest); 34 | } 35 | 36 | public override async onload(): Promise { 37 | console.group("Loading Unicode Search plugin"); 38 | console.time("Unicode Search load time"); 39 | 40 | console.info("Creating services"); 41 | 42 | const dataLoader = new PersistCache( 43 | () => this.loadData(), 44 | (data) => this.saveData(data) 45 | ); 46 | 47 | /* TODO [rework]: Data stores duplicate access to data */ 48 | const dataStore = new RootPluginDataStorage(dataLoader); 49 | const metaStore = new MetaStorage(dataStore); 50 | const codepointStore = new CodepointStorage(dataStore); 51 | const usageStore = new CodepointUsageStorage(dataStore); 52 | const favoritesStore = new CodepointFavoritesStorage(dataStore); 53 | const characterService = new UserCharacterService(codepointStore, usageStore, favoritesStore); 54 | const filterStore = new FilterStorage(dataStore, metaStore); 55 | 56 | /* TODO [rework]: Downloader needs filter data, but is before update of char mng. */ 57 | const downloader = new UcdUserFilterDownloader(filterStore); 58 | 59 | const metaDm = new MetaDataManager(); 60 | const filterDm = new FilterDataManager(); 61 | const unicodeDm = new UnicodeDataManager(downloader); 62 | const usageDm = new UsageDataManager(); 63 | const favoritesDm = new FavoritesDataManager(); 64 | 65 | const dataManager = new RootDataManager( 66 | dataLoader, 67 | metaDm, 68 | filterDm, 69 | unicodeDm, 70 | usageDm, 71 | favoritesDm, 72 | ); 73 | 74 | await dataManager.initializeData(); 75 | 76 | console.info("Adding UI elements"); 77 | 78 | const commandAdder = new Commander(this); 79 | commandAdder.addModal(characterService) 80 | await commandAdder.addFavorites(favoritesStore); 81 | 82 | this.addSettingTab(new SettingTab( 83 | this.app, 84 | this, 85 | characterService, 86 | favoritesStore, 87 | filterStore, 88 | dataManager, 89 | )); 90 | 91 | console.timeEnd("Unicode Search load time"); 92 | console.groupEnd(); 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/unicode-search/service/characterDownloader.ts: -------------------------------------------------------------------------------- 1 | import {UnicodeCodepoint} from "../../libraries/types/codepoint/unicode"; 2 | 3 | export interface CharacterDownloader { 4 | download(): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/unicode-search/service/characterService.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Character, 3 | CharacterKey, 4 | FavoriteCharacter, 5 | MaybeUsedCharacter, 6 | UsedCharacter 7 | } from "../../libraries/types/codepoint/character"; 8 | 9 | 10 | import {UsageInfo} from "../../libraries/types/savedata/usageInfo"; 11 | 12 | export interface CharacterService { 13 | getOne(key: CharacterKey): Promise; 14 | getAllCharacters(): Promise; 15 | getUsed(): Promise; 16 | getFavorites(): Promise; 17 | getAll(): Promise; 18 | recordUsage(key: CharacterKey): Promise; 19 | } 20 | -------------------------------------------------------------------------------- /src/unicode-search/service/codePointStore.ts: -------------------------------------------------------------------------------- 1 | import {UnicodeCodepoint} from "../../libraries/types/codepoint/unicode"; 2 | 3 | export interface CodepointStore { 4 | 5 | /** 6 | * Retrieve all characters. 7 | */ 8 | getCodepoints(): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /src/unicode-search/service/codepointFavoritesStorage.ts: -------------------------------------------------------------------------------- 1 | import {FavoritesStore} from "./favoritesStore"; 2 | import {CharacterKey} from "../../libraries/types/codepoint/character"; 3 | import {serializeFavoriteInfo} from "../../libraries/helpers/serializeFavoriteInfo"; 4 | import {UnicodeSearchError} from "../errors/unicodeSearchError"; 5 | import {RootDataStore} from "./rootDataStore"; 6 | import {FavoritesFragment} from "../../libraries/types/savedata/favoritesFragment"; 7 | 8 | import {CodepointFavorite} from "../../libraries/types/codepoint/extension"; 9 | import {ParsedFavoriteInfo} from "../../libraries/types/savedata/favoriteInfo"; 10 | 11 | export class CodepointFavoritesStorage implements FavoritesStore { 12 | 13 | constructor( 14 | private readonly store: RootDataStore, 15 | ) { 16 | } 17 | 18 | async upsert( 19 | key: CharacterKey, 20 | apply: (char?: ParsedFavoriteInfo) => ParsedFavoriteInfo 21 | ): Promise { 22 | const data = await this.getFavorites(); 23 | 24 | const foundIndex = data.findIndex(ch => ch.codepoint === key); 25 | const found = foundIndex >= 0; 26 | const index = found ? foundIndex : 0; 27 | 28 | const modified = { 29 | ...apply(found ? {...data[index]} : undefined), 30 | codepoint: key, 31 | }; 32 | 33 | if (found) { 34 | data[foundIndex] = modified; 35 | } else { 36 | data.unshift(modified); 37 | } 38 | 39 | await this.overwriteFavoritesData(data); 40 | 41 | return data[index]; 42 | } 43 | 44 | async update( 45 | key: CharacterKey, 46 | apply: (char: ParsedFavoriteInfo) => Partial 47 | ): Promise { 48 | const data = await this.getFavorites(); 49 | 50 | const foundIndex = data.findIndex(ch => ch.codepoint === key); 51 | 52 | if (foundIndex < 0) { 53 | throw new UnicodeSearchError(`No character '${key}' exists in favorites.`); 54 | } 55 | 56 | data[foundIndex] = { 57 | ...data[foundIndex], 58 | ...apply({...data[foundIndex]}), 59 | codepoint: key, 60 | } as CodepointFavorite; 61 | 62 | await this.overwriteFavoritesData(data); 63 | 64 | return data[foundIndex]; 65 | } 66 | 67 | async addFavorite(key: CharacterKey): Promise { 68 | const favorites = await this.getFavorites(); 69 | const foundFavorite = favorites.find(fav => fav.codepoint === key); 70 | 71 | if (foundFavorite != null) { 72 | return foundFavorite; 73 | } 74 | 75 | const newFavorite: CodepointFavorite = { 76 | codepoint: key, 77 | added: new Date(), 78 | hotkey: false 79 | }; 80 | 81 | favorites.unshift(newFavorite); 82 | await this.overwriteFavoritesData(favorites); 83 | 84 | return newFavorite; 85 | } 86 | 87 | async removeFavorite(key: CharacterKey): Promise { 88 | console.log(`Removing favorite ${key}`); 89 | 90 | let favorites = await this.getFavorites(); 91 | favorites = favorites.filter(fav => fav.codepoint !== key); 92 | await this.overwriteFavoritesData(favorites); 93 | } 94 | 95 | async getFavorites(): Promise { 96 | return (await this.store.getFavorites()).codepoints.map(fav => ({ 97 | ...fav, 98 | added: new Date(fav.added), 99 | })); 100 | } 101 | 102 | private async overwriteFavoritesData(data: CodepointFavorite[]): Promise { 103 | const newData = await this.mergeFavorites({ 104 | codepoints: data.map(serializeFavoriteInfo), 105 | }); 106 | 107 | await this.store.overwriteFavorites(newData); 108 | } 109 | 110 | private async mergeFavorites(data: Partial): Promise { 111 | const storedData = await this.store.getFavorites(); 112 | 113 | const newData = { 114 | ...storedData, 115 | ...data 116 | }; 117 | 118 | return await this.store.overwriteFavorites(newData); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/unicode-search/service/codepointStorage.ts: -------------------------------------------------------------------------------- 1 | import {CodepointStore} from "./codePointStore"; 2 | import {UnicodeCodepoint} from "../../libraries/types/codepoint/unicode"; 3 | import {RootDataStore} from "./rootDataStore"; 4 | 5 | export class CodepointStorage implements CodepointStore { 6 | 7 | constructor(private readonly store: RootDataStore) { 8 | } 9 | 10 | async getCodepoints(): Promise { 11 | return (await this.store.getUnicode()).codepoints; 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/unicode-search/service/codepointUsageStorage.ts: -------------------------------------------------------------------------------- 1 | import {UsageStore} from "./usageStore"; 2 | import {CharacterKey} from "../../libraries/types/codepoint/character"; 3 | import {parseUsageInfo} from "../../libraries/helpers/parseUsageInfo"; 4 | import {serializeUsageInfo} from "../../libraries/helpers/serializeUsageInfo"; 5 | import {RootDataStore} from "./rootDataStore"; 6 | import {CharacterUseFragment} from "../../libraries/types/savedata/usageFragment"; 7 | 8 | import {CodepointUse} from "../../libraries/types/codepoint/extension"; 9 | import {UsageInfo} from "../../libraries/types/savedata/usageInfo"; 10 | 11 | export class CodepointUsageStorage implements UsageStore { 12 | 13 | constructor( 14 | private readonly store: RootDataStore, 15 | ) { 16 | } 17 | 18 | async upsert( 19 | key: CharacterKey, 20 | apply: (char?: UsageInfo) => UsageInfo 21 | ): Promise 22 | { 23 | const data = await this.getUsed(); 24 | 25 | const foundIndex = data.findIndex(ch => ch.codepoint === key); 26 | const found = foundIndex >= 0; 27 | const index = found ? foundIndex : 0; 28 | 29 | const modified = { 30 | ...apply(found ? {...data[index]} : undefined), 31 | codepoint: key, 32 | }; 33 | 34 | if (found) { 35 | data[foundIndex] = modified 36 | } else { 37 | data.unshift(modified) 38 | } 39 | 40 | await this.overwriteUsageData(data) 41 | 42 | return data[index]; 43 | } 44 | 45 | async getUsed(): Promise { 46 | return (await this.store.getUsage()) 47 | .codepoints 48 | .map(parseUsageInfo) 49 | } 50 | 51 | private async overwriteUsageData(data: CodepointUse[]): Promise { 52 | const newData = await this.mergeUsage({ 53 | codepoints: data.map(serializeUsageInfo), 54 | }); 55 | 56 | return newData.codepoints.map(parseUsageInfo) 57 | } 58 | 59 | private async mergeUsage(data: Partial): Promise { 60 | const storedData = await this.store.getUsage(); 61 | 62 | const newData = { 63 | ...storedData, 64 | ...data 65 | }; 66 | 67 | return await this.store.overwriteUsage(newData); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/unicode-search/service/commander.ts: -------------------------------------------------------------------------------- 1 | import {Plugin} from "obsidian"; 2 | import {FavoritesStore} from "./favoritesStore"; 3 | import {toHexadecimal} from "../../libraries/helpers/toHexadecimal"; 4 | import {CharacterService} from "./characterService"; 5 | import {InsertCharacterModal} from "../components/insertCharacterModal"; 6 | import {CodepointKey} from "../../libraries/types/codepoint/unicode"; 7 | 8 | export class Commander { 9 | constructor( 10 | private readonly plugin: Plugin, 11 | ) { 12 | } 13 | 14 | addModal(characters: CharacterService) { 15 | this.plugin.addCommand({ 16 | id: "search-unicode-chars", 17 | name: "Search Unicode characters", 18 | 19 | editorCallback: editor => { 20 | const modal = new InsertCharacterModal( 21 | this.plugin.app, 22 | characters, 23 | editor, 24 | ); 25 | modal.open(); 26 | return true; 27 | }, 28 | }); 29 | } 30 | 31 | async addFavorites(favorites: FavoritesStore) { 32 | const hotkeys = (await favorites.getFavorites()) 33 | .filter(favorite => favorite.hotkey); 34 | 35 | for (const character of hotkeys) { 36 | this.addCommandFor(character); 37 | } 38 | } 39 | 40 | private addCommandFor(character: CodepointKey) { 41 | this.plugin.addCommand({ 42 | id: `insert-${toHexadecimal(character)}`, 43 | name: `Insert '${character.codepoint}'`, 44 | repeatable: true, 45 | 46 | editorCallback: editor => { 47 | editor.replaceSelection(character.codepoint); 48 | }, 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/unicode-search/service/dataFragmentManager.ts: -------------------------------------------------------------------------------- 1 | import {DataFragment} from "../../libraries/types/savedata/dataFragment"; 2 | import {DataEvent} from "../../libraries/types/savedata/metaFragment"; 3 | 4 | /** 5 | * Manages the lifecycle of a structured data segment, typically stored as JSON. 6 | * Responsible for initialization and updates of data parts. 7 | */ 8 | export interface DataFragmentManager { 9 | /** 10 | * Initializes the data structure with default/empty values. 11 | * @param fragment to be populated with default values 12 | * @returns the initialized data with defaults applied 13 | */ 14 | initData(fragment: DataFragment): Fragment; 15 | 16 | /** 17 | * Migrates data between different versions when the data structure changes. 18 | * Handles both structural changes and data transformations. 19 | * @param fragment to be updated to the current version 20 | * @param events to be handled by the updater 21 | * @returns the updated data 22 | */ 23 | updateData(fragment: Fragment, events: Set): Promise; 24 | } 25 | -------------------------------------------------------------------------------- /src/unicode-search/service/dataManager.ts: -------------------------------------------------------------------------------- 1 | export interface DataManager { 2 | initializeData(): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/unicode-search/service/favoritesDataManager.ts: -------------------------------------------------------------------------------- 1 | import {DataFragmentManager} from "./dataFragmentManager"; 2 | import {FavoritesFragment} from "../../libraries/types/savedata/favoritesFragment"; 3 | import {SaveDataVersion} from "../../libraries/types/savedata/version"; 4 | import {DataEvent} from "../../libraries/types/savedata/metaFragment"; 5 | import {DataFragment} from "../../libraries/types/savedata/dataFragment"; 6 | 7 | export class FavoritesDataManager implements DataFragmentManager { 8 | initData(fragment: DataFragment): FavoritesFragment { 9 | if (fragment.initialized && isFavoritesFragment(fragment)) { 10 | return fragment; 11 | } 12 | 13 | console.info("Initializing favorites"); 14 | 15 | return { 16 | ...fragment, 17 | initialized: true, 18 | codepoints: [], 19 | }; 20 | } 21 | 22 | async updateData(fragment: FavoritesFragment, _: Set): Promise { 23 | /* No-op yet */ 24 | return fragment; 25 | } 26 | 27 | } 28 | 29 | function isFavoritesFragment(fragment: DataFragment): fragment is FavoritesFragment { 30 | return "codepoints" in fragment 31 | && Array.isArray(fragment.codepoints) 32 | ; 33 | } 34 | -------------------------------------------------------------------------------- /src/unicode-search/service/favoritesStore.ts: -------------------------------------------------------------------------------- 1 | import { CharacterKey } from "../../libraries/types/codepoint/character"; 2 | 3 | import {CodepointFavorite} from "../../libraries/types/codepoint/extension"; 4 | import {ParsedFavoriteInfo} from "../../libraries/types/savedata/favoriteInfo"; 5 | 6 | export interface FavoritesStore { 7 | update(key: CharacterKey, apply: (char: ParsedFavoriteInfo) => Partial): Promise; 8 | getFavorites(): Promise; 9 | /* Maybe replace with upsert*/ 10 | addFavorite(key: CharacterKey): Promise; 11 | removeFavorite(key: CharacterKey): Promise; 12 | 13 | 14 | /* Unused so far */ 15 | upsert(key: CharacterKey, apply: (char?: ParsedFavoriteInfo) => ParsedFavoriteInfo): Promise; 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/unicode-search/service/filterDataManager.ts: -------------------------------------------------------------------------------- 1 | import {DataFragmentManager} from "./dataFragmentManager"; 2 | import {UNICODE_PLANES_ALL} from "../../libraries/data/unicodePlanes"; 3 | import {UNICODE_CHARACTER_CATEGORIES} from "../../libraries/data/unicodeCharacterCategories"; 4 | import {UnicodePlaneNumber} from "../../libraries/data/unicodePlaneNumber"; 5 | import {CharacterCategoryGroupType} from "../../libraries/data/characterCategoryGroup"; 6 | import {SaveDataVersion} from "../../libraries/types/savedata/version"; 7 | import {FilterFragment} from "../../libraries/types/savedata/filterFragment"; 8 | import {DataEvent} from "../../libraries/types/savedata/metaFragment"; 9 | import {DataFragment} from "../../libraries/types/savedata/dataFragment"; 10 | import {UnicodeFilter} from "../../libraries/types/savedata/unicodeFilter"; 11 | 12 | export class FilterDataManager implements DataFragmentManager { 13 | 14 | initData(fragment: DataFragment): FilterFragment { 15 | if (fragment.initialized && isFilterFragment(fragment)) { 16 | return fragment; 17 | } 18 | 19 | console.info("Initializing filter"); 20 | 21 | return { 22 | ...fragment, 23 | initialized: true, 24 | unicode: { 25 | planes: UNICODE_PLANES_ALL.map(plane => ({ 26 | ...plane.interval, 27 | blocks: plane.blocks.map(block => ({ 28 | ...block.interval, 29 | included: DATA_DEFAULTS.planes.includes(plane.planeNumber), 30 | })) 31 | })), 32 | categoryGroups: UNICODE_CHARACTER_CATEGORIES.map(group => ({ 33 | abbreviation: group.abbreviation, 34 | categories: group.categories.map(category => ({ 35 | abbreviation: category.abbreviation, 36 | included: DATA_DEFAULTS.categories.includes(group.abbreviation), 37 | })) 38 | })), 39 | } 40 | } 41 | } 42 | 43 | async updateData(fragment: FilterFragment, _: Set): Promise { 44 | /* No-op yet */ 45 | return fragment; 46 | } 47 | 48 | } 49 | 50 | type InclusionDefaults = { 51 | planes: UnicodePlaneNumber[], 52 | categories: CharacterCategoryGroupType[], 53 | }; 54 | 55 | const DATA_DEFAULTS: InclusionDefaults = { 56 | planes: [ 57 | 0, 58 | // 1, 59 | // 2, 60 | // 3, 61 | // 14, 62 | // 15, 63 | // 16 64 | ], 65 | categories: [ 66 | "L", 67 | // "M", 68 | "N", 69 | "P", 70 | "S", 71 | // "Z", 72 | // "C", 73 | ], 74 | } 75 | 76 | function isFilterFragment(object: DataFragment): object is FilterFragment { 77 | return "unicode" in object 78 | && isUnicodeFilter(object.unicode) 79 | ; 80 | } 81 | 82 | function isUnicodeFilter(object: any): object is UnicodeFilter { 83 | return "planes" in object 84 | && Array.isArray(object.planes) 85 | && "categoryGroups" in object 86 | && Array.isArray(object.categoryGroups) 87 | ; 88 | } 89 | -------------------------------------------------------------------------------- /src/unicode-search/service/filterStorage.ts: -------------------------------------------------------------------------------- 1 | import {FilterStore} from "./filterStore"; 2 | import {BlockFilter, CategoryFilter, UnicodeFilter} from "../../libraries/types/savedata/unicodeFilter"; 3 | import {UnicodeSearchError} from "../errors/unicodeSearchError"; 4 | import {CodepointInterval} from "../../libraries/types/codepoint/codepointInterval"; 5 | import {intervalsEqual} from "../../libraries/helpers/intervalsEqual"; 6 | import {intervalWithin} from "../../libraries/helpers/intervalWithin"; 7 | import {CharacterCategoryType} from "../../libraries/data/characterCategory"; 8 | import {RootDataStore} from "./rootDataStore"; 9 | import {MetaStorage} from "./metaStorage"; 10 | import {DataEvent} from "../../libraries/types/savedata/metaFragment"; 11 | 12 | export class FilterStorage implements FilterStore { 13 | 14 | constructor( 15 | private readonly store: RootDataStore, 16 | private readonly meta: MetaStorage, 17 | ) { 18 | } 19 | 20 | async getFilter(): Promise { 21 | return (await this.store.getFilter()).unicode 22 | } 23 | 24 | async getCharacterBlock(block: CodepointInterval): Promise { 25 | return (await this.getBlockFilters()) 26 | .some(blockFilter => intervalsEqual(blockFilter, block) && blockFilter.included); 27 | } 28 | 29 | async setCharacterBlock(block: CodepointInterval, set: boolean): Promise { 30 | const filter = await this.store.getFilter(); 31 | const planeIndex = filter.unicode.planes.findIndex(pf => intervalWithin(pf, block)); 32 | 33 | if (planeIndex < 0) { 34 | throw new UnicodeSearchError(`Block doesn't belong to any codepoint plane. ${block}`); 35 | } 36 | 37 | const blockIndex = filter.unicode.planes[planeIndex].blocks.findIndex(bf => intervalsEqual(bf, block)); 38 | 39 | if (blockIndex < 0) { 40 | throw new UnicodeSearchError(`Block doesn't exist within a plane. ${block}`); 41 | } 42 | 43 | filter.unicode.planes[planeIndex].blocks[blockIndex].included = set; 44 | await this.meta.request(DataEvent.DownloadCharacters); 45 | 46 | await this.store.overwriteFilter(filter); 47 | } 48 | 49 | async getCharacterCategory(category: CharacterCategoryType): Promise { 50 | return (await this.getCategoryFilters()) 51 | .some(filter => filter.abbreviation === category && filter.included); 52 | } 53 | 54 | async setCharacterCategory(category: CharacterCategoryType, set: boolean): Promise { 55 | const filter = await this.store.getFilter(); 56 | const groupIndex = filter.unicode.categoryGroups.findIndex(gf => gf.abbreviation === category[0]); 57 | 58 | if (groupIndex < 0) { 59 | throw new UnicodeSearchError(`Codepoint category group doesn't exist. ${category}: ${category[0]}`); 60 | } 61 | 62 | const categoryIndex = filter.unicode.categoryGroups[groupIndex].categories.findIndex(cf => cf.abbreviation === category); 63 | 64 | if (categoryIndex < 0) { 65 | throw new UnicodeSearchError(`Block doesn't exist within a plane. ${category}`); 66 | } 67 | 68 | filter.unicode.categoryGroups[groupIndex].categories[categoryIndex].included = set; 69 | await this.meta.request(DataEvent.DownloadCharacters); 70 | 71 | await this.store.overwriteFilter(filter); 72 | } 73 | 74 | private async getBlockFilters(): Promise { 75 | return (await this.getFilter()).planes 76 | .flatMap(plane => plane.blocks); 77 | } 78 | 79 | private async getCategoryFilters(): Promise { 80 | return (await this.getFilter()).categoryGroups 81 | .flatMap(group => group.categories); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/unicode-search/service/filterStore.ts: -------------------------------------------------------------------------------- 1 | import {UnicodeFilter} from "../../libraries/types/savedata/unicodeFilter"; 2 | import {CodepointInterval} from "../../libraries/types/codepoint/codepointInterval"; 3 | import {CharacterCategoryType} from "../../libraries/data/characterCategory"; 4 | 5 | export interface FilterStore { 6 | getFilter(): Promise 7 | 8 | getCharacterBlock(block: CodepointInterval): Promise 9 | setCharacterBlock(block: CodepointInterval, set: boolean): Promise 10 | 11 | getCharacterCategory(category: CharacterCategoryType): Promise 12 | setCharacterCategory(category: CharacterCategoryType, set: boolean): Promise 13 | } 14 | -------------------------------------------------------------------------------- /src/unicode-search/service/metaDataManager.ts: -------------------------------------------------------------------------------- 1 | import {DataFragmentManager} from "./dataFragmentManager"; 2 | import {DataEvent, isDataEvent, MetaFragment} from "../../libraries/types/savedata/metaFragment"; 3 | import {CURRENT_PLUGIN_VERSION, SaveDataVersion} from "../../libraries/types/savedata/version"; 4 | import {DataFragment} from "../../libraries/types/savedata/dataFragment"; 5 | 6 | export class MetaDataManager implements DataFragmentManager { 7 | initData(fragment: DataFragment): MetaFragment { 8 | if (fragment.initialized && isMetaFragment(fragment)) { 9 | return fragment; 10 | } 11 | 12 | console.info("Initializing metadata"); 13 | 14 | return { 15 | ...fragment, 16 | pluginVersion: CURRENT_PLUGIN_VERSION, 17 | initialized: true, 18 | events: [], 19 | }; 20 | } 21 | 22 | async updateData(fragment: MetaFragment, _: Set): Promise { 23 | /* No-op yet */ 24 | return fragment; 25 | } 26 | 27 | } 28 | 29 | function isMetaFragment(fragment: DataFragment): fragment is MetaFragment { 30 | return "events" in fragment 31 | && fragment.events != null 32 | && Array.isArray(fragment.events) 33 | && fragment.events.every(e => isDataEvent(e)) 34 | ; 35 | } 36 | -------------------------------------------------------------------------------- /src/unicode-search/service/metaStorage.ts: -------------------------------------------------------------------------------- 1 | import {RootDataStore} from "./rootDataStore"; 2 | import {DataEvent} from "../../libraries/types/savedata/metaFragment"; 3 | 4 | export class MetaStorage { 5 | 6 | constructor(private readonly store: RootDataStore) { 7 | } 8 | 9 | async request(event: DataEvent): Promise { 10 | const meta = await this.store.getMeta(); 11 | meta.events.push(event); 12 | await this.store.overwriteMeta(meta) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/unicode-search/service/rootDataManager.ts: -------------------------------------------------------------------------------- 1 | import {FilterDataManager} from "./filterDataManager"; 2 | import {PersistCache} from "../../libraries/types/persistCache"; 3 | import { 4 | SaveData, 5 | SaveDataOf 6 | } from "../../libraries/types/savedata/saveData"; 7 | import {UnicodeDataManager} from "./unicodeDataManager"; 8 | import {UsageDataManager} from "./usageDataManager"; 9 | import {FavoritesDataManager} from "./favoritesDataManager"; 10 | import {isTypeDataFragment} from "../../libraries/helpers/isTypeSaveData"; 11 | import {DataManager} from "./dataManager"; 12 | import {MetaDataManager} from "./metaDataManager"; 13 | import {DataFragment} from "../../libraries/types/savedata/dataFragment"; 14 | import {CURRENT_DATA_VERSION} from "../../libraries/types/savedata/version"; 15 | import {MetaFragment} from "../../libraries/types/savedata/metaFragment"; 16 | import {isInitialSaveData} from "../../libraries/types/savedata/initialSaveData"; 17 | 18 | type MetaSaveDataFragments = Omit, "meta"> & { meta: MetaFragment }; 19 | 20 | export class RootDataManager implements DataManager { 21 | constructor( 22 | private readonly storedData: PersistCache, 23 | private readonly metaDm: MetaDataManager, 24 | private readonly filterDm: FilterDataManager, 25 | private readonly unicodeDm: UnicodeDataManager, 26 | private readonly usageDm: UsageDataManager, 27 | private readonly favoritesDm: FavoritesDataManager, 28 | ) { 29 | } 30 | 31 | async initializeData(): Promise { 32 | console.group("Save data initialization"); 33 | /* We don't know what we will load */ 34 | const loadedData: any = (await this.storedData.get()) ?? {}; 35 | 36 | /* First, migrate data from initial release */ 37 | const migratedData = RootDataManager.initialMigration(loadedData); 38 | 39 | /* Make sure the data is well-shaped */ 40 | const shapedData = RootDataManager.shapeLoadedData(migratedData); 41 | 42 | /* Full initialization of meta-data, because other fragments need to process its events */ 43 | const loadedDataWithMeta = await this.initMeta(shapedData); 44 | 45 | /* Now we let each data fragment handle initialization of its data if needed */ 46 | console.group("Initializing data"); 47 | const initializedData = this.initData(loadedDataWithMeta); 48 | console.groupEnd(); 49 | 50 | /* After this, each data fragment manager can request fragments data */ 51 | this.storedData.set(initializedData); 52 | 53 | /* Each data fragment can update its data if needed */ 54 | console.group("Updating data"); 55 | const upToDateData = await this.updateData(initializedData); 56 | console.groupEnd(); 57 | 58 | /* Finally, persist the data */ 59 | console.info("Saving initialized data"); 60 | this.storedData.set(upToDateData); 61 | await this.storedData.persist(); 62 | console.groupEnd(); 63 | } 64 | 65 | private async initMeta(fragments: SaveDataOf): Promise { 66 | /* Does the skeleton have data? */ 67 | const initializedMeta = this.metaDm.initData(fragments.meta); 68 | 69 | /* Is the data up to date with the latest data version? */ 70 | const upToDateMeta = await this.metaDm.updateData(initializedMeta, new Set([])); 71 | 72 | /* Check and create the shape of save-data if missing */ 73 | return { 74 | ...fragments, 75 | meta: upToDateMeta, 76 | }; 77 | } 78 | 79 | private initData(fragments: MetaSaveDataFragments): SaveData { 80 | const filterData = this.filterDm.initData(fragments.filter); 81 | const unicodeData = this.unicodeDm.initData(fragments.characters); 82 | const usageData = this.usageDm.initData(fragments.usage); 83 | const favoritesData = this.favoritesDm.initData(fragments.favorites); 84 | 85 | return { 86 | ...fragments, 87 | filter: filterData, 88 | characters: unicodeData, 89 | usage: usageData, 90 | favorites: favoritesData, 91 | }; 92 | } 93 | 94 | private async updateData(initializedData: SaveData): Promise { 95 | /* We load the meta-data first, to be able to process events like re-downloading of characters etc. */ 96 | const metaData = await this.metaDm.updateData(initializedData.meta, new Set([])); 97 | const events = new Set(metaData.events); 98 | console.info("Events to process", events); 99 | 100 | /* All the other updates see the events, and handle them accordingly */ 101 | const filterData = await this.filterDm.updateData(initializedData.filter, events); 102 | const unicodeData = await this.unicodeDm.updateData(initializedData.characters, events); 103 | const usageData = await this.usageDm.updateData(initializedData.usage, events); 104 | const favoritesData = await this.favoritesDm.updateData(initializedData.favorites, events); 105 | 106 | console.info("Unprocessed events", events); 107 | metaData.events = Array.from(events); 108 | 109 | return { 110 | meta: metaData, 111 | filter: filterData, 112 | characters: unicodeData, 113 | usage: usageData, 114 | favorites: favoritesData, 115 | }; 116 | } 117 | 118 | private static shapeLoadedData(loadedData: any): SaveDataOf { 119 | /* Check and create the shape of save-data if missing 120 | * Removing any element will remove it from save data 121 | */ 122 | return { 123 | meta: RootDataManager.createFragment(loadedData.meta), 124 | filter: RootDataManager.createFragment(loadedData.filter), 125 | usage: RootDataManager.createFragment(loadedData.usage), 126 | characters: RootDataManager.createFragment(loadedData.characters), 127 | favorites: RootDataManager.createFragment(loadedData.favorites), 128 | }; 129 | } 130 | 131 | private static createFragment(dataPart: any): DataFragment { 132 | return isTypeDataFragment(dataPart) 133 | ? dataPart 134 | : { 135 | initialized: false, 136 | version: CURRENT_DATA_VERSION, 137 | }; 138 | } 139 | 140 | /** 141 | * Migration of data created before the save-data update 142 | * Only data version of "0.6.0" is migrated 143 | */ 144 | private static initialMigration(loadedData: any): Pick { 145 | const shouldMigrate = isInitialSaveData(loadedData) 146 | && loadedData.initialized 147 | && loadedData.version === "0.6.0"; 148 | 149 | if (!shouldMigrate) { 150 | return loadedData; 151 | } 152 | 153 | console.info("Migrating from data version 0.6.0"); 154 | 155 | return { 156 | filter: { 157 | version: loadedData.version, 158 | ...loadedData.settings, 159 | unicode: loadedData.settings.filter, 160 | }, 161 | usage: { 162 | version: loadedData.version, 163 | ...loadedData.usage 164 | }, 165 | characters: { 166 | version: loadedData.version, 167 | ...loadedData.unicode 168 | }, 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/unicode-search/service/rootDataStore.ts: -------------------------------------------------------------------------------- 1 | import {UnicodeFragment} from "../../libraries/types/savedata/unicodeFragment"; 2 | import {FilterFragment} from "../../libraries/types/savedata/filterFragment"; 3 | import {CharacterUseFragment} from "../../libraries/types/savedata/usageFragment"; 4 | import {FavoritesFragment} from "../../libraries/types/savedata/favoritesFragment"; 5 | import {MetaFragment} from "../../libraries/types/savedata/metaFragment"; 6 | 7 | export interface RootDataStore { 8 | getMeta(): Promise 9 | overwriteMeta(data: MetaFragment): Promise 10 | 11 | getUnicode(): Promise 12 | overwriteUnicode(data: UnicodeFragment): Promise 13 | 14 | getFilter(): Promise 15 | overwriteFilter(settings: FilterFragment): Promise 16 | 17 | getUsage(): Promise 18 | overwriteUsage(usage: CharacterUseFragment): Promise 19 | 20 | getFavorites(): Promise 21 | overwriteFavorites(favorites: FavoritesFragment): Promise 22 | } 23 | -------------------------------------------------------------------------------- /src/unicode-search/service/rootPluginDataStorage.ts: -------------------------------------------------------------------------------- 1 | import {PersistCache} from "../../libraries/types/persistCache"; 2 | import {SaveData} from "../../libraries/types/savedata/saveData"; 3 | import {FilterFragment} from "../../libraries/types/savedata/filterFragment"; 4 | import {UnicodeFragment} from "../../libraries/types/savedata/unicodeFragment"; 5 | import {CharacterUseFragment} from "../../libraries/types/savedata/usageFragment"; 6 | import {FavoritesFragment} from "../../libraries/types/savedata/favoritesFragment"; 7 | import {RootDataStore} from "./rootDataStore"; 8 | import {MetaFragment} from "../../libraries/types/savedata/metaFragment"; 9 | 10 | export class RootPluginDataStorage implements RootDataStore { 11 | 12 | constructor( 13 | private readonly storedData: PersistCache, 14 | ) { 15 | } 16 | 17 | async getMeta(): Promise { 18 | return (await this.storedData.get()).meta; 19 | } 20 | 21 | async overwriteMeta(data: MetaFragment): Promise { 22 | const mergedData = await this.mergeData({ 23 | meta: data, 24 | }); 25 | 26 | return mergedData.meta; 27 | } 28 | 29 | async getUnicode(): Promise { 30 | return (await this.storedData.get()).characters; 31 | } 32 | 33 | async overwriteUnicode(data: UnicodeFragment): Promise { 34 | const mergedData = await this.mergeData({ 35 | characters: data, 36 | }); 37 | 38 | return mergedData.characters; 39 | } 40 | 41 | async getFilter(): Promise { 42 | return (await this.storedData.get()).filter 43 | } 44 | 45 | async overwriteFilter(filter: FilterFragment): Promise { 46 | const mergedData = await this.mergeData({ 47 | filter: filter, 48 | }); 49 | 50 | return mergedData.filter; 51 | } 52 | 53 | async getUsage(): Promise { 54 | return (await this.storedData.get()).usage; 55 | } 56 | 57 | async overwriteUsage(usage: CharacterUseFragment): Promise { 58 | const mergedData = await this.mergeData({ 59 | usage: usage, 60 | }); 61 | 62 | return mergedData.usage; 63 | } 64 | 65 | async getFavorites(): Promise { 66 | return (await this.storedData.get()).favorites; 67 | } 68 | 69 | async overwriteFavorites(favorites: FavoritesFragment): Promise { 70 | const mergedData = await this.mergeData({ 71 | favorites: favorites, 72 | }); 73 | 74 | return mergedData.favorites; 75 | } 76 | 77 | private async mergeData(data: Partial): Promise { 78 | const storedData = await this.storedData.get(); 79 | 80 | const newData: SaveData = { 81 | ...storedData, 82 | ...data, 83 | }; 84 | 85 | this.storedData.set(newData); 86 | return await this.storedData.persist(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/unicode-search/service/ucdUserFilterDownloader.ts: -------------------------------------------------------------------------------- 1 | import {getIcon, Notice, request, requestUrl} from "obsidian"; 2 | import {parse, ParseConfig, ParseResult, ParseWorkerConfig} from "papaparse"; 3 | import {UnicodeSearchError} from "../errors/unicodeSearchError"; 4 | import {UnicodeCodepoint} from "../../libraries/types/codepoint/unicode"; 5 | import {CharacterDownloader} from "./characterDownloader"; 6 | import {FilterStore} from "./filterStore"; 7 | import {mergeIntervals} from "../../libraries/helpers/mergeIntervals"; 8 | import {codepointIn} from "../../libraries/helpers/codePointIn"; 9 | import {CharacterCategoryType} from "../../libraries/data/characterCategory"; 10 | import {CodepointInterval} from "../../libraries/types/codepoint/codepointInterval"; 11 | 12 | export class UcdUserFilterDownloader implements CharacterDownloader { 13 | 14 | private readonly config: ParseConfig = { 15 | delimiter: ";", 16 | header: false, 17 | transformHeader: undefined, 18 | dynamicTyping: false, 19 | fastMode: true, 20 | }; 21 | 22 | public constructor( 23 | private readonly filterStore: FilterStore, 24 | ) { 25 | } 26 | 27 | public async download(): Promise { 28 | /* NOTE: You must also push a GIT mirror of the UCD version to the `ucd-mirror` branch */ 29 | const unicodeVersion = "14.0.0"; 30 | 31 | let info = "Unicode Search: Character Database Update"; 32 | info += `\nUCD version ${unicodeVersion}` 33 | const notice = new Notice(info, 0); 34 | 35 | const sourceUcd = "https://www.unicode.org"; 36 | const sourceGit = "https://raw.githubusercontent.com/BambusControl/obsidian-unicode-search/refs/heads/ucd-mirror" 37 | const path = `Public/${unicodeVersion}/ucd/UnicodeData.txt` 38 | 39 | let response = await requestUrl({url: `${sourceUcd}/${path}`, throw: false}); 40 | 41 | if (response.status != 200) { 42 | info += `\n✗ Failed to download from Unicode: HTTP ${response.status}`; 43 | notice.setMessage(info) 44 | 45 | response = await requestUrl({url: `${sourceGit}/${path}`, throw: false}); 46 | 47 | if (response.status != 200) { 48 | info += `\n✗ Failed to download from GIT: HTTP ${response.status}`; 49 | notice.setMessage(info) 50 | return []; 51 | } 52 | } 53 | 54 | info = info + `\n✓ Successfully downloaded characters`; 55 | 56 | const parsed = await this.transformToCharacters(response.text); 57 | const filtered = await this.filterCharacters(parsed); 58 | const unicode = filtered.map(intoUnicodeCodepoint); 59 | 60 | info += `\n✱ Filtered ${unicode.length} out of ${parsed.length} total characters`; 61 | notice.setMessage(info); 62 | setTimeout(() => notice.hide(), 6 * 1000); 63 | 64 | return unicode; 65 | } 66 | 67 | private async filterCharacters(parsed: ParsedCharacter[]): Promise { 68 | const filter = await this.filterStore.getFilter(); 69 | 70 | const includedBlocks = mergeIntervals(filter.planes 71 | .flatMap(p => p.blocks) 72 | .filter(b => b.included)); 73 | 74 | const includedCategories = filter.categoryGroups 75 | .flatMap(p => p.categories) 76 | .filter(c => c.included) 77 | .map(c => c.abbreviation); 78 | 79 | return parsed.filter(char => 80 | !containsNullValues(char) 81 | && includedInBlocks(char, includedBlocks) 82 | && categoryIncluded(char, includedCategories) 83 | ); 84 | } 85 | 86 | private transformToCharacters(csvString: string): Promise { 87 | return new Promise((resolve, reject) => { 88 | const completeFn = (results: ParseResult): void => { 89 | if (results.errors.length !== 0) { 90 | reject(new UnicodeSearchError("Error while parsing data from Unicode Character Database")); 91 | } 92 | 93 | const parsedCharacters = results.data 94 | .map((row): ParsedCharacter => ({ 95 | codepoint: parseInt(row[0], 16), 96 | name: row[1], 97 | category: row[2], 98 | })); 99 | 100 | resolve(parsedCharacters); 101 | }; 102 | 103 | const configuration: ParseWorkerConfig = { 104 | ...this.config, 105 | worker: true, 106 | complete: results => completeFn(results), 107 | }; 108 | 109 | parse(csvString, configuration); 110 | }); 111 | } 112 | 113 | } 114 | 115 | type ParsedData = string[]; 116 | 117 | type ParsedCharacter = { 118 | codepoint: number; 119 | name: string; 120 | category: string; 121 | }; 122 | 123 | function containsNullValues(char: Partial): boolean { 124 | return char == null 125 | || char.name == null 126 | || char.codepoint == null 127 | || char.category == null 128 | } 129 | 130 | function includedInBlocks(character: Pick, includedBlocks: CodepointInterval[]): boolean { 131 | return includedBlocks.some( 132 | (block) => codepointIn(character.codepoint, block) 133 | ); 134 | } 135 | 136 | function categoryIncluded(character: Pick, includedCategories: CharacterCategoryType[]): boolean { 137 | return includedCategories.some( 138 | (category) => character.category === category 139 | ); 140 | } 141 | 142 | function intoUnicodeCodepoint(char: ParsedCharacter): UnicodeCodepoint { 143 | return { 144 | codepoint: String.fromCodePoint(char.codepoint), 145 | name: char.name.toLowerCase(), 146 | category: char.category 147 | }; 148 | } 149 | -------------------------------------------------------------------------------- /src/unicode-search/service/unicodeDataManager.ts: -------------------------------------------------------------------------------- 1 | import {DataFragmentManager} from "./dataFragmentManager"; 2 | import {UnicodeFragment} from "../../libraries/types/savedata/unicodeFragment"; 3 | import {SaveDataVersion} from "../../libraries/types/savedata/version"; 4 | import {isCodepointKey} from "../../libraries/helpers/isTypeSaveData"; 5 | import {CharacterDownloader} from "./characterDownloader"; 6 | import {DataEvent} from "../../libraries/types/savedata/metaFragment"; 7 | import {DataFragment} from "../../libraries/types/savedata/dataFragment"; 8 | import {UnicodeCodepoint} from "../../libraries/types/codepoint/unicode"; 9 | 10 | export class UnicodeDataManager implements DataFragmentManager { 11 | constructor( 12 | private readonly ucdService: CharacterDownloader, 13 | ) { 14 | } 15 | 16 | initData(fragment: DataFragment): UnicodeFragment { 17 | if (fragment.initialized && isUnicodeFragment(fragment)) { 18 | return fragment; 19 | } 20 | 21 | console.info("Initializing characters"); 22 | 23 | return { 24 | ...fragment, 25 | initialized: true, 26 | codepoints: [], 27 | }; 28 | } 29 | 30 | async updateData(fragment: UnicodeFragment, events: Set): Promise { 31 | const updatedData = this.updateByVersion(fragment); 32 | const downloadRequested = events.has(DataEvent.DownloadCharacters); 33 | const emptyCharacterSet = fragment.codepoints.length < 1; 34 | 35 | if (!(downloadRequested || emptyCharacterSet)) { 36 | return updatedData; 37 | } 38 | 39 | console.info("Downloading character database"); 40 | const codepoints = await this.ucdService.download(); 41 | 42 | /* Yeah, we modify the input parameter */ 43 | events.delete(DataEvent.DownloadCharacters); 44 | 45 | return { 46 | ...updatedData, 47 | codepoints: codepoints, 48 | }; 49 | } 50 | 51 | private updateByVersion(fragment: UnicodeFragment): UnicodeFragment { 52 | /* No-op yet */ 53 | return fragment; 54 | } 55 | } 56 | 57 | export function isUnicodeFragment(fragment: DataFragment): fragment is UnicodeFragment { 58 | return "codepoints" in fragment 59 | && fragment.codepoints != null 60 | && Array.isArray(fragment.codepoints) 61 | && fragment.codepoints.every(isUnicodeCodepoint) 62 | ; 63 | } 64 | 65 | function isUnicodeCodepoint(object: any): object is UnicodeCodepoint { 66 | return isCodepointKey(object) 67 | 68 | && "name" in object 69 | && object.name != null 70 | && typeof object.name === "string" 71 | 72 | && "category" in object 73 | && object.category != null 74 | && typeof object.category === "string" 75 | ; 76 | } 77 | 78 | -------------------------------------------------------------------------------- /src/unicode-search/service/usageDataManager.ts: -------------------------------------------------------------------------------- 1 | import {DataFragmentManager} from "./dataFragmentManager"; 2 | import {CharacterUseFragment} from "../../libraries/types/savedata/usageFragment"; 3 | import {SaveDataVersion} from "../../libraries/types/savedata/version"; 4 | import {isCodepointKey} from "../../libraries/helpers/isTypeSaveData"; 5 | import {DataEvent} from "../../libraries/types/savedata/metaFragment"; 6 | import {DataFragment} from "../../libraries/types/savedata/dataFragment"; 7 | 8 | 9 | import {RawCodepointUse} from "../../libraries/types/codepoint/extension"; 10 | 11 | export class UsageDataManager implements DataFragmentManager { 12 | initData(fragment: DataFragment): CharacterUseFragment { 13 | if (fragment.initialized && isUsageFragment(fragment)) { 14 | return fragment; 15 | } 16 | 17 | console.info("Initializing usage"); 18 | 19 | return { 20 | ...fragment, 21 | initialized: true, 22 | codepoints: [], 23 | }; 24 | } 25 | 26 | async updateData(fragment: CharacterUseFragment, _: Set): Promise { 27 | /* No-op yet */ 28 | return fragment; 29 | } 30 | 31 | } 32 | 33 | function isUsageFragment(fragment: DataFragment): fragment is CharacterUseFragment { 34 | return "codepoints" in fragment 35 | && fragment.codepoints != null 36 | && Array.isArray(fragment.codepoints) 37 | && fragment.codepoints.every(isCodepointUsage) 38 | ; 39 | } 40 | 41 | function isCodepointUsage(object: any): object is RawCodepointUse { 42 | return isCodepointKey(object) 43 | 44 | && "firstUsed" in object 45 | && object.firstUsed != null 46 | && typeof object.firstUsed === "string" 47 | 48 | && "lastUsed" in object 49 | && object.lastUsed != null 50 | && typeof object.lastUsed === "string" 51 | 52 | && "useCount" in object 53 | && object.useCount != null 54 | && typeof object.useCount === "number" 55 | } 56 | -------------------------------------------------------------------------------- /src/unicode-search/service/usageStore.ts: -------------------------------------------------------------------------------- 1 | import {CharacterKey} from "../../libraries/types/codepoint/character"; 2 | 3 | 4 | import {CodepointUse} from "../../libraries/types/codepoint/extension"; 5 | import {UsageInfo} from "../../libraries/types/savedata/usageInfo"; 6 | 7 | export interface UsageStore { 8 | upsert(key: CharacterKey, apply: (char?: UsageInfo) => UsageInfo): Promise; 9 | getUsed(): Promise; 10 | } 11 | -------------------------------------------------------------------------------- /src/unicode-search/service/userCharacterService.ts: -------------------------------------------------------------------------------- 1 | import {UnicodeSearchError} from "../errors/unicodeSearchError"; 2 | import { 3 | Character, 4 | CharacterKey, FavoriteCharacter, MaybeUsedCharacter, 5 | UsedCharacter 6 | } from "../../libraries/types/codepoint/character"; 7 | import {CodepointStore} from "./codePointStore"; 8 | import {CharacterService} from "./characterService"; 9 | import {UsageStore} from "./usageStore"; 10 | 11 | 12 | import {FavoritesStore} from "./favoritesStore"; 13 | import {UsageInfo} from "../../libraries/types/savedata/usageInfo"; 14 | 15 | export class UserCharacterService implements CharacterService { 16 | 17 | public constructor( 18 | private readonly codepointStore: CodepointStore, 19 | private readonly usageStore: UsageStore, 20 | private readonly favoritesStore: FavoritesStore, 21 | ) { 22 | } 23 | 24 | public async getOne(key: CharacterKey): Promise { 25 | const characters = await this.getAllCharacters(); 26 | const char = characters.find(char => char.codepoint === key); 27 | 28 | if (char == null) { 29 | throw new UnicodeSearchError(`No character '${key}' exists.`); 30 | } 31 | 32 | return char; 33 | } 34 | 35 | public getAllCharacters(): Promise { 36 | return this.codepointStore.getCodepoints(); 37 | } 38 | 39 | public async getUsed(): Promise { 40 | const allCharacters = await this.getAllCharacters(); 41 | const usedCharacters = await this.usageStore.getUsed(); 42 | const usedKeys = usedCharacters.map(ch => ch.codepoint); 43 | 44 | return allCharacters 45 | .filter(ch => usedKeys.contains(ch.codepoint)) 46 | .map(character => ({ 47 | ...usedCharacters.find(usage => usage.codepoint === character.codepoint)!, 48 | ...character, 49 | })); 50 | } 51 | 52 | public async getFavorites(): Promise { 53 | const allCharacters = await this.getAllCharacters(); 54 | const favorite = await this.favoritesStore.getFavorites(); 55 | const favoriteKeys = favorite.map(ch => ch.codepoint); 56 | 57 | return allCharacters 58 | .filter(ch => favoriteKeys.contains(ch.codepoint)) 59 | .map(character => ({ 60 | ...favorite.find(usage => usage.codepoint === character.codepoint)!, 61 | ...character, 62 | })); 63 | } 64 | 65 | public async getAll(): Promise { 66 | const allCharacters = await this.getAllCharacters(); 67 | const favoriteCharacters = await this.favoritesStore.getFavorites(); 68 | const usedCharacters = await this.usageStore.getUsed(); 69 | 70 | return allCharacters.map(character => ({ 71 | ...favoriteCharacters.find(fav => fav.codepoint === character.codepoint), 72 | ...usedCharacters.find(usage => usage.codepoint === character.codepoint), 73 | ...character, 74 | })); 75 | } 76 | 77 | public recordUsage(key: CharacterKey): Promise { 78 | const timestamp = new Date(); 79 | 80 | return this.usageStore.upsert(key, (current) => ({ 81 | ...current, 82 | firstUsed: current?.firstUsed ?? timestamp, 83 | lastUsed: timestamp, 84 | useCount: (current?.useCount ?? 0) + 1, 85 | })) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/unicode-search/styles.scss: -------------------------------------------------------------------------------- 1 | .plugin.unicode-search { 2 | --custom-checkbox-radius-modifier: 1; 3 | 4 | &.result-item { 5 | display: flex; 6 | gap: 1em; 7 | justify-content: flex-start; 8 | align-items: center; 9 | 10 | & > .character-preview { 11 | font-size: 1.5em; 12 | width: 1.75em; 13 | 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | } 18 | 19 | & > .character-match { 20 | flex-grow: 1; 21 | 22 | display: flex; 23 | flex-direction: column; 24 | justify-content: center; 25 | align-items: flex-start; 26 | 27 | column-gap: 1em; 28 | row-gap: 0.05em; 29 | 30 | min-height: 2.5em; 31 | 32 | @media (min-width: 40em) { 33 | flex-direction: row; 34 | justify-content: space-between; 35 | align-items: center; 36 | } 37 | 38 | & > .character-name { 39 | letter-spacing: 0.025em; 40 | text-transform: capitalize; 41 | } 42 | 43 | & > .character-codepoint { 44 | color: var(--icon-color); 45 | font-family: var(--font-monospace), monospace; 46 | font-size: 0.7em; 47 | letter-spacing: 0.1em; 48 | text-transform: uppercase; 49 | 50 | &::before { 51 | content: "U+"; 52 | letter-spacing: 0.15em; 53 | } 54 | 55 | & > .suggestion-highlight { 56 | color: var(--text-normal); 57 | } 58 | } 59 | } 60 | 61 | & > .detail { 62 | display: flex; 63 | flex-direction: row; 64 | justify-content: flex-end; 65 | align-items: center; 66 | 67 | & > .attributes { 68 | display: flex; 69 | flex-direction: column; 70 | flex-wrap: wrap; 71 | justify-content: center; 72 | align-items: center; 73 | align-content: center; 74 | font-size: var(--font-smallest); 75 | width: 1.25em; 76 | 77 | & > .favorite { 78 | font-size: 1.25em; 79 | } 80 | } 81 | } 82 | } 83 | 84 | & .icon { 85 | color: var(--icon-color); 86 | opacity: var(--icon-opacity); 87 | 88 | &:hover { 89 | color: var(--icon-color-hover); 90 | opacity: var(--icon-opacity-hover); 91 | 92 | &.interactive { 93 | color: var(--interactive-accent-hover); 94 | } 95 | } 96 | } 97 | 98 | &.setting-tab { 99 | .group-control { 100 | .checkbox-container { 101 | font-size: calc(var(--font-smallest) * 0.9); 102 | 103 | --toggle-thumb-height: 1.5em; 104 | --toggle-thumb-width: var(--toggle-thumb-height); 105 | --toggle-width: calc(4em + var(--toggle-thumb-height)); 106 | 107 | border-radius: calc(var(--checkbox-radius) * var(--custom-checkbox-radius-modifier)); 108 | background-color: var(--background-modifier-border-hover); 109 | 110 | transition: background-color 0.15s ease-in-out, outline 0.15s ease-in-out, border 0.15s ease-in-out, opacity 0.15s ease-in-out, 111 | color 0.15s ease-in-out, outline 0.15s ease-in-out, border 0.15s ease-in-out, opacity 0.15s ease-in-out; 112 | ; 113 | 114 | &:before { 115 | opacity: 100%; 116 | color: var(--text-muted); 117 | padding: 0 .5em; 118 | line-height: 1.85em; 119 | text-transform: uppercase; 120 | 121 | content: "show"; 122 | text-align: end; 123 | } 124 | 125 | &:after { 126 | border-radius: calc(var(--checkbox-radius) * var(--custom-checkbox-radius-modifier)); 127 | background-color: var(--text-muted); 128 | } 129 | 130 | &.is-enabled { 131 | background-color: var(--background-modifier-border-hover); 132 | 133 | &:before { 134 | content: "hide"; 135 | text-align: start; 136 | } 137 | 138 | &:after { 139 | background-color: var(--interactive-accent); 140 | } 141 | } 142 | } 143 | } 144 | 145 | .favorite-control { 146 | .checkbox-container { 147 | font-size: calc(var(--font-smallest) * 0.9); 148 | 149 | --toggle-thumb-height: 1.5em; 150 | --toggle-thumb-width: var(--toggle-thumb-height); 151 | --toggle-width: calc(4em + var(--toggle-thumb-height)); 152 | 153 | border-radius: calc(var(--checkbox-radius) * var(--custom-checkbox-radius-modifier)); 154 | background-color: var(--background-modifier-border-hover); 155 | 156 | transition: background-color 0.15s ease-in-out, outline 0.15s ease-in-out, border 0.15s ease-in-out, opacity 0.15s ease-in-out, 157 | color 0.15s ease-in-out, outline 0.15s ease-in-out, border 0.15s ease-in-out, opacity 0.15s ease-in-out; 158 | ; 159 | 160 | &:before { 161 | opacity: 100%; 162 | color: var(--text-muted); 163 | padding: 0 .5em; 164 | line-height: 1.85em; 165 | text-transform: uppercase; 166 | 167 | content: "INSERT"; 168 | text-align: end; 169 | } 170 | 171 | &:after { 172 | border-radius: calc(var(--checkbox-radius) * var(--custom-checkbox-radius-modifier)); 173 | background-color: var(--text-muted); 174 | } 175 | 176 | &.is-enabled { 177 | background-color: var(--background-modifier-border-hover); 178 | 179 | &:before { 180 | content: "DEL"; 181 | text-align: start; 182 | } 183 | 184 | &:after { 185 | background-color: var(--interactive-accent); 186 | } 187 | } 188 | } 189 | } 190 | 191 | .group-container { 192 | gap: 1em; 193 | flex-direction: column; 194 | align-items: stretch; 195 | align-content: stretch; 196 | flex-wrap: wrap; 197 | justify-content: flex-start; 198 | 199 | &:not(.hidden) { 200 | display: flex; 201 | } 202 | 203 | &.hidden { 204 | display: none; 205 | } 206 | } 207 | 208 | .item-container { 209 | border: 1px solid var(--background-modifier-border); 210 | border-radius: var(--radius-s); 211 | padding: .5em .75em; 212 | background-color: var(--background-primary-alt); 213 | 214 | .setting-item.setting-item-heading { 215 | 216 | &:first-child { 217 | padding-top: 0; 218 | } 219 | 220 | @media (min-width: 40em) { 221 | margin: .75em 0; 222 | } 223 | 224 | & > .setting-item-info { 225 | 226 | & > .setting-item-name { 227 | display: flex; 228 | flex-direction: row; 229 | flex-wrap: wrap; 230 | justify-content: space-between; 231 | align-items: center; 232 | } 233 | } 234 | } 235 | } 236 | 237 | @media (min-width: 40em) { 238 | .blocks-list .setting-item { 239 | & > .setting-item-info { 240 | display: inline-flex; 241 | flex-direction: row; 242 | flex-wrap: wrap; 243 | justify-content: space-between; 244 | align-items: center; 245 | 246 | 247 | & > .setting-item-name { 248 | font-size: 90%; 249 | } 250 | 251 | & > .setting-item-description { 252 | padding-top: 0; 253 | } 254 | } 255 | 256 | & > .setting-item-control { 257 | flex-grow: 0; 258 | } 259 | } 260 | } 261 | 262 | .character-list.no-first { 263 | & > .setting-item { 264 | padding: 0.75em 0; 265 | border-top: 1px solid var(--background-modifier-border); 266 | } 267 | } 268 | 269 | .character-list.new { 270 | flex-direction: column-reverse; 271 | } 272 | 273 | @media (min-width: 40em) { 274 | .character-list .setting-item { 275 | 276 | & > .setting-item-info { 277 | display: inline-flex; 278 | flex-direction: row; 279 | flex-wrap: wrap; 280 | justify-content: start; 281 | align-items: center; 282 | gap: 1em; 283 | 284 | & > .setting-item-name { 285 | font-size: 1.5em; 286 | width: 1.75em; 287 | 288 | display: flex; 289 | justify-content: center; 290 | align-items: center; 291 | } 292 | 293 | & > .setting-item-description { 294 | padding-top: 0; 295 | letter-spacing: 0.025em; 296 | text-transform: capitalize; 297 | } 298 | } 299 | 300 | & > .setting-item-control { 301 | flex-grow: 0; 302 | } 303 | } 304 | } 305 | 306 | .monospace { 307 | color: var(--code-normal); 308 | font-family: var(--font-monospace); 309 | } 310 | 311 | .character-codepoint { 312 | letter-spacing: 0.055em; 313 | text-transform: uppercase; 314 | font-size: var(--code-size); 315 | 316 | @media (min-width: 40em) { 317 | border-radius: var(--radius-s); 318 | background-color: var(--code-background); 319 | padding: 0.1em 0.3em; 320 | } 321 | 322 | & > .suggestion-highlight { 323 | color: var(--text-normal); 324 | } 325 | } 326 | 327 | .setting-item { 328 | &.focus-control { 329 | 330 | & > .setting-item-info { 331 | width: 0; 332 | } 333 | 334 | & > .setting-item-control { 335 | flex-grow: 1; 336 | 337 | & > input:only-child { 338 | width: 100%; 339 | } 340 | 341 | } 342 | 343 | } 344 | 345 | &.codepoint-interval { 346 | 347 | & > .setting-item-info { 348 | flex-grow: 1; 349 | } 350 | 351 | & > .setting-item-control { 352 | flex-grow: 0; 353 | } 354 | 355 | } 356 | 357 | &.prompt-input { 358 | & input { 359 | width: 100%; 360 | padding: var(--size-4-3); 361 | padding-inline-end: var(--size-4-6); 362 | background-color: var(--background-primary); 363 | font-size: var(--font-ui-medium); 364 | border: none; 365 | height: var(--prompt-input-height); 366 | border-radius: 0; 367 | border-bottom: 1px solid var(--background-secondary); 368 | } 369 | } 370 | } 371 | 372 | .favorite-settings .setting-item-control { 373 | button:not(.clickable-icon) { 374 | width: revert; 375 | } 376 | } 377 | } 378 | 379 | 380 | } 381 | -------------------------------------------------------------------------------- /tests/libraries/comparison/compareCodepoints.spec.ts: -------------------------------------------------------------------------------- 1 | import {compareCodepoints} from "../../../src/libraries/comparison/compareCodepoints"; 2 | 3 | test( 4 | "character with codepoint `a` is before character with codepoint `b`", 5 | () => { 6 | expect(compareCodepoints( 7 | { 8 | codepoint: "a", 9 | name: "", 10 | category: "Ll", 11 | }, 12 | { 13 | codepoint: "b", 14 | name: "", 15 | category: "Ll", 16 | }, 17 | )).toBe(-1); 18 | } 19 | ); 20 | 21 | test( 22 | "character with codepoint `b` is after character with codepoint `a`", 23 | () => { 24 | expect(compareCodepoints( 25 | { 26 | codepoint: "b", 27 | name: "", 28 | category: "Ll", 29 | }, 30 | { 31 | codepoint: "a", 32 | name: "", 33 | category: "Ll", 34 | }, 35 | )).toBe(1); 36 | } 37 | ); 38 | 39 | test( 40 | "character with codepoint `a` is equal to character with codepoint `a`", 41 | () => { 42 | expect(compareCodepoints( 43 | { 44 | codepoint: "a", 45 | name: "", 46 | category: "Ll", 47 | }, 48 | { 49 | codepoint: "a", 50 | name: "", 51 | category: "Ll", 52 | }, 53 | )).toBe(0); 54 | } 55 | ); 56 | 57 | test( 58 | "character with codepoint `A` is before character with codepoint `a`", 59 | () => { 60 | expect(compareCodepoints( 61 | { 62 | codepoint: "A", 63 | name: "", 64 | category: "Lu", 65 | }, 66 | { 67 | codepoint: "a", 68 | name: "", 69 | category: "Ll", 70 | }, 71 | )).toBe(-1); 72 | } 73 | ); 74 | 75 | test( 76 | "character with codepoint `z` is after character with codepoint `y`", 77 | () => { 78 | expect(compareCodepoints( 79 | { 80 | codepoint: "z", 81 | name: "", 82 | category: "Ll", 83 | }, 84 | { 85 | codepoint: "y", 86 | name: "", 87 | category: "Ll", 88 | }, 89 | )).toBe(1); 90 | } 91 | ); 92 | -------------------------------------------------------------------------------- /tests/libraries/comparison/compareDates.test.ts: -------------------------------------------------------------------------------- 1 | import { compareDates } from "../../../src/libraries/comparison/compareDates"; 2 | import { Order } from "../../../src/libraries/order/order"; 3 | 4 | describe("compareDates", () => { 5 | it("should return Order.Smaller when the left date is earlier than the right date", () => { 6 | const left = new Date("2023-01-01"); 7 | const right = new Date("2023-01-02"); 8 | expect(compareDates(left, right)).toBe(Order.Smaller); 9 | }); 10 | 11 | it("should return Order.Greater when the left date is later than the right date", () => { 12 | const left = new Date("2023-01-02"); 13 | const right = new Date("2023-01-01"); 14 | expect(compareDates(left, right)).toBe(Order.Greater); 15 | }); 16 | 17 | it("should return Order.Equal when the left date is the same as the right date", () => { 18 | const left = new Date("2023-01-01"); 19 | const right = new Date("2023-01-01"); 20 | expect(compareDates(left, right)).toBe(Order.Equal); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/libraries/comparison/compareFavoriteCharacters.spec.ts: -------------------------------------------------------------------------------- 1 | import {compareFavoriteCharacters} from "../../../src/libraries/comparison/compareFavoriteCharacters"; 2 | 3 | test( 4 | "character which is `favorite` is before character which is not", 5 | () => { 6 | expect(compareFavoriteCharacters( 7 | { 8 | codepoint: " ", 9 | name: "favorite", 10 | category: "Ll", 11 | added: new Date(1), 12 | hotkey: false, 13 | }, 14 | { 15 | codepoint: " ", 16 | name: "not-favorite", 17 | category: "Ll", 18 | }, 19 | )).toBe(-1) 20 | } 21 | ) 22 | -------------------------------------------------------------------------------- /tests/libraries/comparison/compareFavoriteInfo.spec.ts: -------------------------------------------------------------------------------- 1 | import {compareFavoriteInfo} from "../../../src/libraries/comparison/compareFavoriteInfo"; 2 | 3 | test( 4 | "later added is before sooner added", 5 | () => { 6 | expect(compareFavoriteInfo( 7 | { 8 | added: new Date(1), 9 | hotkey: false, 10 | }, 11 | { 12 | added: new Date(0), 13 | hotkey: false, 14 | }, 15 | )).toBe(-1) 16 | } 17 | ) 18 | 19 | test( 20 | "sooner added is after later added", 21 | () => { 22 | expect(compareFavoriteInfo( 23 | { 24 | added: new Date(0), 25 | hotkey: false, 26 | }, 27 | { 28 | added: new Date(1), 29 | hotkey: false, 30 | }, 31 | )).toBe(1) 32 | } 33 | ) 34 | -------------------------------------------------------------------------------- /tests/libraries/comparison/compareNullable.spec.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from "@jest/globals"; 2 | import {compareNullable} from "../../../src/libraries/comparison/compareNullable"; 3 | import {compareNumbers} from "../../../src/libraries/comparison/compareNumbers"; 4 | 5 | test( 6 | "null equals null", 7 | () => { 8 | expect(compareNullable(null, null, compareNumbers)).toBe(0); 9 | } 10 | ) 11 | 12 | test( 13 | "non-null is less than null", 14 | () => { 15 | expect(compareNullable(0, null, compareNumbers)).toBe(-1); 16 | } 17 | ) 18 | 19 | test( 20 | "null is more than non-null", 21 | () => { 22 | expect(compareNullable(null, 0, compareNumbers)).toBe(1); 23 | } 24 | ) 25 | -------------------------------------------------------------------------------- /tests/libraries/comparison/compareNumbers.spec.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from "@jest/globals"; 2 | import {compareNumbers} from "../../../src/libraries/comparison/compareNumbers"; 3 | 4 | test( 5 | "one equals one", 6 | () => { 7 | expect(compareNumbers(1, 1)).toBe(0); 8 | } 9 | ) 10 | 11 | test( 12 | "zero is less than one", 13 | () => { 14 | expect(compareNumbers(0, 1)).toBe(-1); 15 | } 16 | ) 17 | 18 | test( 19 | "two is more than one", 20 | () => { 21 | expect(compareNumbers(2, 1)).toBe(1); 22 | } 23 | ) 24 | -------------------------------------------------------------------------------- /tests/libraries/comparison/compareUsageInfo.spec.ts: -------------------------------------------------------------------------------- 1 | import {compareUsageInfo} from "../../../src/libraries/comparison/compareUsageInfo"; 2 | 3 | test( 4 | "later use is before sooner use", 5 | () => { 6 | expect(compareUsageInfo( 7 | { 8 | firstUsed: new Date(0), 9 | lastUsed: new Date(2), 10 | useCount: 1, 11 | }, 12 | { 13 | firstUsed: new Date(0), 14 | lastUsed: new Date(1), 15 | useCount: 1, 16 | }, 17 | new Date(0), 18 | )).toBe(-1) 19 | } 20 | ) 21 | 22 | test( 23 | "sooner use is after later use", 24 | () => { 25 | expect(compareUsageInfo( 26 | { 27 | firstUsed: new Date(0), 28 | lastUsed: new Date(1), 29 | useCount: 1, 30 | }, 31 | { 32 | firstUsed: new Date(0), 33 | lastUsed: new Date(2), 34 | useCount: 1, 35 | }, 36 | new Date(0), 37 | )).toBe(1) 38 | } 39 | ) 40 | 41 | test( 42 | "one use is before zero uses", 43 | () => { 44 | expect(compareUsageInfo( 45 | { 46 | useCount: 1, 47 | firstUsed: new Date(0), 48 | lastUsed: new Date(0), 49 | }, 50 | { 51 | useCount: 0, 52 | firstUsed: new Date(0), 53 | lastUsed: new Date(0), 54 | }, 55 | new Date(0), 56 | )).toBe(-1) 57 | } 58 | ) 59 | 60 | test( 61 | "zero uses is after one use", 62 | () => { 63 | expect(compareUsageInfo( 64 | { 65 | useCount: 0, 66 | firstUsed: new Date(0), 67 | lastUsed: new Date(0), 68 | }, 69 | { 70 | useCount: 1, 71 | firstUsed: new Date(0), 72 | lastUsed: new Date(0), 73 | }, 74 | new Date(0), 75 | )).toBe(1) 76 | } 77 | ) 78 | -------------------------------------------------------------------------------- /tests/libraries/comparison/compareUsedCharacters.spec.ts: -------------------------------------------------------------------------------- 1 | import {compareUsedCharacters} from "../../../src/libraries/comparison/compareUsedCharacters"; 2 | 3 | test( 4 | "character with `use` is before character without", 5 | () => { 6 | expect(compareUsedCharacters( 7 | { 8 | codepoint: " ", 9 | name: "b", 10 | category: "Ll", 11 | lastUsed: new Date(2), 12 | firstUsed: new Date(1), 13 | useCount: 1 14 | }, 15 | { 16 | codepoint: " ", 17 | name: "a", 18 | category: "Ll", 19 | }, 20 | new Date(0), 21 | )).toBe(-1) 22 | } 23 | ) 24 | 25 | test( 26 | "characters with same `name`, `use`, and `favorite` are equal", 27 | () => { 28 | expect(compareUsedCharacters( 29 | { 30 | codepoint: " ", 31 | name: "name", 32 | category: "Ll", 33 | firstUsed: new Date(1), 34 | lastUsed: new Date(1), 35 | useCount: 1, 36 | }, 37 | { 38 | codepoint: " ", 39 | name: "name", 40 | category: "Ll", 41 | firstUsed: new Date(1), 42 | lastUsed: new Date(1), 43 | useCount: 1 44 | }, 45 | new Date(0) 46 | )).toBe(0) 47 | } 48 | ) 49 | -------------------------------------------------------------------------------- /tests/libraries/helpers/averageUseCount.spec.ts: -------------------------------------------------------------------------------- 1 | import { averageUseCount } from "../../../src/libraries/helpers/averageUseCount"; 2 | 3 | describe("averageUseCount", () => { 4 | it("should return 0 when the input array is empty", () => { 5 | const result = averageUseCount([]); 6 | expect(result).toBe(0); 7 | }); 8 | 9 | it("should return the correct average when the input array has one item", () => { 10 | const result = averageUseCount([{ useCount: 5 }]); 11 | expect(result).toBe(5); 12 | }); 13 | 14 | it("should return the correct average when the input array has multiple items", () => { 15 | const result = averageUseCount([{ useCount: 5 }, { useCount: 10 }, { useCount: 15 }]); 16 | expect(result).toBe(10); 17 | }); 18 | 19 | it("should handle an array with all zero useCounts", () => { 20 | const result = averageUseCount([{ useCount: 0 }, { useCount: 0 }, { useCount: 0 }]); 21 | expect(result).toBe(0); 22 | }); 23 | 24 | it("should handle an array with mixed positive and zero useCounts", () => { 25 | const result = averageUseCount([{ useCount: 0 }, { useCount: 10 }, { useCount: 20 }]); 26 | expect(result).toBe(10); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /tests/libraries/helpers/codePointIn.spec.ts: -------------------------------------------------------------------------------- 1 | import { codepointIn } from "../../../src/libraries/helpers/codePointIn"; 2 | import { Codepoint } from "../../../src/libraries/types/codepoint/unicode"; 3 | import { CodepointInterval } from "../../../src/libraries/types/codepoint/codepointInterval"; 4 | 5 | describe("codepointIn", () => { 6 | it("should return true when the codepoint is within the interval", () => { 7 | const codepoint: Codepoint = 65; // 'A' 8 | const interval: CodepointInterval = { start: 60, end: 70 }; 9 | expect(codepointIn(codepoint, interval)).toBe(true); 10 | }); 11 | 12 | it("should return false when the codepoint is less than the interval start", () => { 13 | const codepoint: Codepoint = 50; 14 | const interval: CodepointInterval = { start: 60, end: 70 }; 15 | expect(codepointIn(codepoint, interval)).toBe(false); 16 | }); 17 | 18 | it("should return false when the codepoint is greater than the interval end", () => { 19 | const codepoint: Codepoint = 80; 20 | const interval: CodepointInterval = { start: 60, end: 70 }; 21 | expect(codepointIn(codepoint, interval)).toBe(false); 22 | }); 23 | 24 | it("should return true when the codepoint is equal to the interval start", () => { 25 | const codepoint: Codepoint = 60; 26 | const interval: CodepointInterval = { start: 60, end: 70 }; 27 | expect(codepointIn(codepoint, interval)).toBe(true); 28 | }); 29 | 30 | it("should return true when the codepoint is equal to the interval end", () => { 31 | const codepoint: Codepoint = 70; 32 | const interval: CodepointInterval = { start: 60, end: 70 }; 33 | expect(codepointIn(codepoint, interval)).toBe(true); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/libraries/helpers/hexadecimal.characters.spec.ts: -------------------------------------------------------------------------------- 1 | import {toHexadecimal} from "../../../src/libraries/helpers/toHexadecimal"; 2 | 3 | test( 4 | "character `b` is `0062`", 5 | () => { 6 | expect(toHexadecimal({ 7 | codepoint: "b", 8 | })).toBe("0062") 9 | } 10 | ) 11 | -------------------------------------------------------------------------------- /tests/libraries/helpers/intervalWithin.spec.ts: -------------------------------------------------------------------------------- 1 | import { intervalWithin } from "../../../src/libraries/helpers/intervalWithin"; 2 | import { CodepointInterval } from "../../../src/libraries/types/codepoint/codepointInterval"; 3 | 4 | describe("intervalWithin", () => { 5 | it("should return true when the inner interval is completely within the outer interval", () => { 6 | const outer: CodepointInterval = { start: 10, end: 20 }; 7 | const inner: CodepointInterval = { start: 12, end: 18 }; 8 | expect(intervalWithin(outer, inner)).toBe(true); 9 | }); 10 | 11 | it("should return false when the inner interval starts before the outer interval", () => { 12 | const outer: CodepointInterval = { start: 10, end: 20 }; 13 | const inner: CodepointInterval = { start: 8, end: 18 }; 14 | expect(intervalWithin(outer, inner)).toBe(false); 15 | }); 16 | 17 | it("should return false when the inner interval ends after the outer interval", () => { 18 | const outer: CodepointInterval = { start: 10, end: 20 }; 19 | const inner: CodepointInterval = { start: 12, end: 22 }; 20 | expect(intervalWithin(outer, inner)).toBe(false); 21 | }); 22 | 23 | it("should return true when the inner interval is exactly the same as the outer interval", () => { 24 | const outer: CodepointInterval = { start: 10, end: 20 }; 25 | const inner: CodepointInterval = { start: 10, end: 20 }; 26 | expect(intervalWithin(outer, inner)).toBe(true); 27 | }); 28 | 29 | it("should return false when the inner interval is completely outside the outer interval", () => { 30 | const outer: CodepointInterval = { start: 10, end: 20 }; 31 | const inner: CodepointInterval = { start: 21, end: 30 }; 32 | expect(intervalWithin(outer, inner)).toBe(false); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/libraries/helpers/intervalsEqual.spec.ts: -------------------------------------------------------------------------------- 1 | import {intervalsEqual} from "../../../src/libraries/helpers/intervalsEqual"; 2 | import {CodepointInterval} from "../../../src/libraries/types/codepoint/codepointInterval"; 3 | 4 | describe("intervalsEqual", () => { 5 | it("should return true for intervals with the same start and end", () => { 6 | const interval1: CodepointInterval = { start: 10, end: 20 }; 7 | const interval2: CodepointInterval = { start: 10, end: 20 }; 8 | 9 | expect(intervalsEqual(interval1, interval2)).toBe(true); 10 | }); 11 | 12 | it("should return false for intervals with different start values", () => { 13 | const interval1: CodepointInterval = { start: 10, end: 20 }; 14 | const interval2: CodepointInterval = { start: 15, end: 20 }; 15 | 16 | expect(intervalsEqual(interval1, interval2)).toBe(false); 17 | }); 18 | 19 | it("should return false for intervals with different end values", () => { 20 | const interval1: CodepointInterval = { start: 10, end: 20 }; 21 | const interval2: CodepointInterval = { start: 10, end: 25 }; 22 | 23 | expect(intervalsEqual(interval1, interval2)).toBe(false); 24 | }); 25 | 26 | it("should return false for completely different intervals", () => { 27 | const interval1: CodepointInterval = { start: 5, end: 15 }; 28 | const interval2: CodepointInterval = { start: 10, end: 20 }; 29 | 30 | expect(intervalsEqual(interval1, interval2)).toBe(false); 31 | }); 32 | 33 | it("should return true for intervals with identical start and end values even if they are zero", () => { 34 | const interval1: CodepointInterval = { start: 0, end: 0 }; 35 | const interval2: CodepointInterval = { start: 0, end: 0 }; 36 | 37 | expect(intervalsEqual(interval1, interval2)).toBe(true); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /tests/libraries/helpers/mergeIntervals.spec.ts: -------------------------------------------------------------------------------- 1 | import {CodepointInterval} from "../../../src/libraries/types/codepoint/codepointInterval"; 2 | import {mergeIntervals} from "../../../src/libraries/helpers/mergeIntervals"; 3 | 4 | describe("mergeIntervals", () => { 5 | it("should merge overlapping intervals", () => { 6 | const input: CodepointInterval[] = [ 7 | { start: 1, end: 3 }, 8 | { start: 2, end: 6 }, 9 | { start: 8, end: 10 }, 10 | { start: 15, end: 18 }, 11 | ]; 12 | const expected: CodepointInterval[] = [ 13 | { start: 1, end: 6 }, 14 | { start: 8, end: 10 }, 15 | { start: 15, end: 18 }, 16 | ]; 17 | expect(mergeIntervals(input)).toEqual(expected); 18 | }); 19 | 20 | it("should handle non-overlapping intervals", () => { 21 | const input: CodepointInterval[] = [ 22 | { start: 1, end: 2 }, 23 | { start: 3, end: 4 }, 24 | { start: 5, end: 6 }, 25 | ]; 26 | const expected: CodepointInterval[] = [ 27 | { start: 1, end: 2 }, 28 | { start: 3, end: 4 }, 29 | { start: 5, end: 6 }, 30 | ]; 31 | expect(mergeIntervals(input)).toEqual(expected); 32 | }); 33 | 34 | it("should handle a single interval", () => { 35 | const input: CodepointInterval[] = [{ start: 1, end: 5 }]; 36 | const expected: CodepointInterval[] = [{ start: 1, end: 5 }]; 37 | expect(mergeIntervals(input)).toEqual(expected); 38 | }); 39 | 40 | it("should handle an empty array", () => { 41 | const input: CodepointInterval[] = []; 42 | const expected: CodepointInterval[] = []; 43 | expect(mergeIntervals(input)).toEqual(expected); 44 | }); 45 | 46 | it("should handle intervals that are already merged", () => { 47 | const input: CodepointInterval[] = [ 48 | { start: 1, end: 5 }, 49 | { start: 6, end: 10 }, 50 | ]; 51 | const expected: CodepointInterval[] = [ 52 | { start: 1, end: 5 }, 53 | { start: 6, end: 10 }, 54 | ]; 55 | expect(mergeIntervals(input)).toEqual(expected); 56 | }); 57 | 58 | it("should handle intervals with the same start and end", () => { 59 | const input: CodepointInterval[] = [ 60 | { start: 1, end: 3 }, 61 | { start: 1, end: 3 }, 62 | ]; 63 | const expected: CodepointInterval[] = [{ start: 1, end: 3 }]; 64 | expect(mergeIntervals(input)).toEqual(expected); 65 | }); 66 | 67 | it("should handle unsorted intervals", () => { 68 | const input: CodepointInterval[] = [ 69 | { start: 8, end: 10 }, 70 | { start: 1, end: 3 }, 71 | { start: 2, end: 6 }, 72 | { start: 15, end: 18 }, 73 | ]; 74 | const expected: CodepointInterval[] = [ 75 | { start: 1, end: 6 }, 76 | { start: 8, end: 10 }, 77 | { start: 15, end: 18 }, 78 | ]; 79 | expect(mergeIntervals(input)).toEqual(expected); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "moduleResolution": "node", 10 | "importHelpers": true, 11 | "isolatedModules": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "strict": true, 14 | "noImplicitOverride": true, 15 | "noPropertyAccessFromIndexSignature": true, 16 | "noImplicitReturns": true, 17 | "strictNullChecks": true, 18 | "strictBindCallApply": true, 19 | "strictPropertyInitialization": true, 20 | "strictFunctionTypes": true, 21 | "noImplicitAny": true, 22 | "noImplicitThis": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "lib": [ 25 | "DOM", 26 | "ES5", 27 | "ES6", 28 | "ES7" 29 | ] 30 | }, 31 | "include": [ 32 | "**/*.ts" 33 | ] 34 | } 35 | --------------------------------------------------------------------------------