├── .env ├── .env.dev ├── .env.prod ├── .githooks └── pre-commit ├── .gitignore ├── .prettierrc ├── .storybook ├── main.js ├── obsidian-dark-theme.css └── preview.js ├── LICENSE.md ├── README.md ├── backend ├── .env ├── .gitignore ├── aws │ ├── env.ts │ ├── obsidian-plugin-update-checker-resources.ts │ └── stacks.ts ├── cdk.json ├── get-releases-lambda │ ├── .gitignore │ ├── local │ │ ├── .gitignore │ │ ├── local.ts │ │ └── single-plugin-request.json │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── MetricLogger.ts │ │ ├── PluginRepository.ts │ │ ├── ReleaseApi.ts │ │ ├── ReleaseRepository │ │ ├── DynamoDBReleaseRepository.ts │ │ ├── FallbackReleaseRepository.test.ts │ │ ├── FallbackReleaseRepository.ts │ │ ├── RedisReleaseRepository.ts │ │ └── index.ts │ │ ├── get-releases.test.ts │ │ ├── get-releases.ts │ │ ├── handler.ts │ │ ├── redisClient.ts │ │ └── util.ts ├── package-lock.json ├── package.json └── tsconfig.json ├── esbuild.config.mjs ├── jest.config.js ├── manifest-beta.json ├── manifest.json ├── oput-common ├── index.d.ts └── semverCompare.ts ├── package-lock.json ├── package.json ├── scripts ├── change-plugin-version.sh ├── dataview-v0.5.44-main.sample ├── exceed-github-rate-limit.ts ├── node_modules-obsidian │ └── index ├── repo-setup.sh ├── reset.sh └── run-storybook.sh ├── src ├── components │ ├── DismissedPluginVersions.tsx │ ├── PluginUpdateList.stories.tsx │ ├── PluginUpdateList.tsx │ ├── PluginUpdateManager.tsx │ ├── PluginUpdateProgressTracker.stories.tsx │ ├── PluginUpdateProgressTracker.tsx │ ├── RibbonIcon.tsx │ ├── SelectedPluginActionBar.tsx │ ├── UpdateStatusIcon.tsx │ ├── common │ │ └── NewTextFadeOutThenInAnimation.tsx │ └── hooks │ │ └── usePluginReleaseFilter.ts ├── domain │ ├── InstalledPluginReleases.ts │ ├── api.ts │ ├── initiatePluginSettings.test.ts │ ├── initiatePluginSettings.ts │ ├── pluginFilter.test.ts │ ├── pluginFilter.ts │ ├── pluginSettings.ts │ ├── releaseNoteEnricher.test.ts │ ├── releaseNoteEnricher.ts │ └── util │ │ ├── groupById.ts │ │ ├── pluralize.ts │ │ ├── semverCompare.test.ts │ │ └── sleep.ts ├── main.tsx └── state │ ├── actionProducers │ ├── acknowledgeUpdateResult.ts │ ├── cleanupDismissedPluginVersions.ts │ ├── dismissPluginVersions.ts │ ├── fetchReleases.ts │ ├── showUpdateNotification.ts │ ├── syncApp.ts │ ├── undismissPluginVersion.ts │ └── updatePlugins.ts │ ├── index.ts │ ├── obsidianReducer.ts │ ├── releasesReducer.ts │ └── selectors │ ├── countSelectedPlugins.ts │ └── getSelectedPluginIds.ts ├── tsconfig.json └── versions.json /.env: -------------------------------------------------------------------------------- 1 | OBSIDIAN_APP_RELEASE_MIN_POLLING_HOURS=0.5 2 | OBSIDIAN_APP_POLLING_FREQUENCY_MULTIPLIER=3600000 3 | OBSIDIAN_APP_INSTALLED_VERSION_POLLING_SECONDS=30 4 | OBSIDIAN_APP_SIMULATE_UPDATE_PLUGINS=false 5 | OBSIDIAN_APP_HIDE_THIS_PLUGINS_UPDATES=false 6 | OBSIDIAN_APP_THIS_PLUGIN_ID=obsidian-plugin-update-tracker 7 | OBSIDIAN_APP_ACTION_BAR_LOCATION_MIDDLE=false 8 | -------------------------------------------------------------------------------- /.env.dev: -------------------------------------------------------------------------------- 1 | OBSIDIAN_APP_ENABLE_REDUX_LOGGER=true 2 | OBSIDIAN_APP_UPDATE_CHECKER_URL=https://374xrhrucwlywxoigq76se2j7e0ttdmt.lambda-url.us-east-1.on.aws 3 | OBSIDIAN_APP_SHOW_STATUS_BAR_ICON_ALL_PLATFORMS=true 4 | OBSIDIAN_APP_SHOW_RIBBON_ICON_ALL_PLATFORMS=true -------------------------------------------------------------------------------- /.env.prod: -------------------------------------------------------------------------------- 1 | OBSIDIAN_APP_ENABLE_REDUX_LOGGER=false 2 | OBSIDIAN_APP_UPDATE_CHECKER_URL=https://jc5gpa3gs7o2uge6iq5mgjd53q0daomi.lambda-url.us-east-1.on.aws 3 | OBSIDIAN_APP_SHOW_STATUS_BAR_ICON_ALL_PLATFORMS=false 4 | OBSIDIAN_APP_SHOW_RIBBON_ICON_ALL_PLATFORMS=false -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npx organize-imports-cli tsconfig.json; 4 | git diff --name-only --diff-filter=AMUX HEAD | grep "\.ts" | xargs -I {} npx prettier --write "{}"; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env.local.dev 2 | /.env.local.prod 3 | *.js 4 | !jest.config.js 5 | !scripts/node-modules/obsidian/index.js 6 | manifest.json.bak 7 | styles.css 8 | 9 | # Intellij 10 | *.iml 11 | .idea 12 | 13 | /node_modules/* 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "singleQuote": true 6 | } -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "stories": [ 3 | "../src/components/*.stories.@(ts|tsx)" 4 | ], 5 | "addons": [ 6 | "@storybook/addon-links", 7 | "@storybook/addon-essentials", 8 | "@storybook/addon-interactions" 9 | ], 10 | "framework": "@storybook/react" 11 | } -------------------------------------------------------------------------------- /.storybook/obsidian-dark-theme.css: -------------------------------------------------------------------------------- 1 | body { 2 | --font-default: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Microsoft YaHei Light", sans-serif; 3 | --font-interface-override: '??'; 4 | --font-interface-theme: '??'; 5 | --font-interface: var(--font-interface-override), var(--font-interface-theme), var(--default-font, '??'), var(--font-default); 6 | --font-text-override: '??'; 7 | --font-text-theme: '??'; 8 | --font-text: var(--font-text-override), var(--font-text-theme), var(--font-interface); 9 | --font-monospace-override: '??'; 10 | --font-monospace-theme: '??'; 11 | --font-monospace: var(--font-monospace-override), var(--font-monospace-theme), 'Source Code Pro', monospace; 12 | --font-text-size: 16px; 13 | --font-mermaid: var(--font-text); 14 | } 15 | 16 | :root { 17 | /* Headers */ 18 | --h1: 2em; 19 | --h2: 1.6em; 20 | --h3: 1.37em; 21 | --h4: 1.25em; 22 | --h5: 1.12em; 23 | --h6: 1.12em; 24 | --h1-weight: 700; 25 | --h2-weight: 600; 26 | --h3-weight: 600; 27 | --h4-weight: 600; 28 | --h5-weight: 600; 29 | --h6-weight: 600; 30 | --h1-variant: normal; 31 | --h2-variant: normal; 32 | --h3-variant: normal; 33 | --h4-variant: normal; 34 | --h5-variant: normal; 35 | --h6-variant: normal; 36 | --h1-style: normal; 37 | --h2-style: normal; 38 | --h3-style: normal; 39 | --h4-style: normal; 40 | --h5-style: normal; 41 | --h6-style: normal; 42 | /* Weights */ 43 | --bold-weight: 700; 44 | /* Cursor */ 45 | --cursor: pointer; 46 | /* Borders */ 47 | --border-width: 2px; 48 | } 49 | 50 | body { 51 | --background-primary: #202020; 52 | --background-primary-rgb: 32, 32, 32; 53 | --background-primary-alt: #1a1a1a; 54 | --background-secondary: #161616; 55 | --background-secondary-alt: #000000; 56 | --background-modifier-border: #333; 57 | --background-modifier-form-field: rgba(0, 0, 0, 0.3); 58 | --background-modifier-form-field-highlighted: rgba(0, 0, 0, 0.22); 59 | --background-modifier-box-shadow: rgba(0, 0, 0, 0.3); 60 | --background-modifier-success: #197300; 61 | --background-modifier-success-rgb: 25, 115, 0; 62 | --background-modifier-error: #3d0000; 63 | --background-modifier-error-rgb: 61, 0, 0; 64 | --background-modifier-error-hover: #470000; 65 | --background-modifier-cover: rgba(0, 0, 0, 0.8); 66 | --text-accent: #7f6df2; 67 | --text-accent-hover: #8875ff; 68 | --text-normal: #dcddde; 69 | --text-muted: #999; 70 | --text-muted-rgb: 153, 153, 153; 71 | --text-faint: #666; 72 | --text-error: #ff3333; 73 | --text-error-hover: #990000; 74 | --text-highlight-bg: rgba(255, 255, 0, 0.4); 75 | --text-highlight-bg-active: rgba(255, 128, 0, 0.4); 76 | --text-selection: rgba(23, 48, 77, 0.99); 77 | --text-on-accent: #dcddde; 78 | --interactive-normal: #2a2a2a; 79 | --interactive-hover: #303030; 80 | --interactive-accent: #483699; 81 | --interactive-accent-rgb: 72, 54, 153; 82 | --interactive-accent-hover: #4d3ca6; 83 | --interactive-success: #197300; 84 | --scrollbar-active-thumb-bg: rgba(255, 255, 255, 0.2); 85 | --scrollbar-bg: rgba(255, 255, 255, 0.05); 86 | --scrollbar-thumb-bg: rgba(255, 255, 255, 0.1); 87 | --highlight-mix-blend-mode: lighten; 88 | color-scheme: dark; 89 | 90 | --layer-cover: 5; 91 | --layer-sidedock: 10; 92 | --layer-status-bar: 15; 93 | --layer-popover: 30; 94 | --layer-slides: 45; 95 | --layer-modal: 50; 96 | --layer-notice: 60; 97 | --layer-menu: 65; 98 | --layer-tooltip: 70; 99 | --layer-dragged-item: 80; 100 | } 101 | 102 | code[class*="language-"], 103 | pre[class*="language-"] { 104 | color: #f8f8f2; 105 | background: none; 106 | } 107 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import './obsidian-dark-theme.css' 2 | 3 | export const parameters = { 4 | actions: { argTypesRegex: "^on[A-Z].*" }, 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/, 9 | }, 10 | }, 11 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Steven Swartz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Plugin Update Tracker 2 | 3 | - [Installation Link](#installation) 4 | - [Features](#features) 5 | * [Know when installed plugins have updates](#know-when-installed-plugins-have-updates) 6 | * [View a list of updates](#view-a-list-of-updates) 7 | * [Read the release notes to see what's changed](#read-the-release-notes-to-see-whats-changed) 8 | * [Install Plugin Updates](#install-plugin-updates) 9 | * [Evaluate the riskiness of upgrading](#evaluate-the-riskiness-of-upgrading) 10 | - [Statistics on new versions](#statistics-on-new-versions) 11 | - [Wait a few days before showing updates](#wait-a-few-days-before-showing-updates) 12 | - [View code changes between versions of a plugin](#view-code-changes-between-versions-of-a-plugin) 13 | * [View and install new beta plugin versions](#view-and-install-new-beta-plugin-versions) 14 | * [Ignore specific plugin updates](#ignore-specific-plugin-updates) 15 | * [Customizing plugin appearance](#customizing-plugin-appearance) 16 | - [Using the public API to check for plugin updates](#using-the-public-api-to-check-for-plugin-updates) 17 | * [API Privacy](#api-privacy) 18 | 19 | # Installation 20 | Visit this URL: obsidian://show-plugin?id=obsidian-plugin-update-tracker 21 | 22 | # Features 23 | 24 | ## Know when installed plugins have updates 25 | 26 | **Desktop:** 27 | 28 | This small icon is added to the status bar: 29 | 30 | ![image](https://user-images.githubusercontent.com/17691679/193410461-5882744b-670a-4cf9-9606-2864dba148d1.png): When 5 plugins have updates 31 | 32 | ![image](https://user-images.githubusercontent.com/17691679/193410447-395cb124-289d-4e92-b236-4d313bdc6bc8.png): When all plugins are up-to-date 33 | 34 | **Mobile/Tablet:** 35 | 36 | This ribbon action is shown when there's at least one update available: 37 | 38 | ![image](https://user-images.githubusercontent.com/17691679/201494579-ec481261-728d-4ac1-81dd-2d754420bf69.png) 39 | 40 | ## View a list of updates 41 | 42 | When updates are available, click the plugin icon to see the list: 43 | 44 | image 45 | 46 | ## Read the release notes to see what's changed 47 | 48 | Release notes are safely rendered as markdown 49 | 50 | ![image](https://user-images.githubusercontent.com/17691679/193410513-047060a1-c631-4b48-81f1-204ff6011714.png) 51 | 52 | ![image](https://user-images.githubusercontent.com/17691679/193410498-88444760-8c97-4da2-a0ac-9d4e384886d8.png) 53 | 54 | ## Install Plugin Updates 55 | 56 | ![Screen_Recording_2022-10-29_at_3_43_48_PM_AdobeExpress (1)](https://user-images.githubusercontent.com/17691679/198850850-5f61dba0-31a7-4d9e-aab5-8776850d3897.gif) 57 | 58 | ## Evaluate the riskiness of upgrading 59 | 60 | #### Statistics on new versions 61 | 62 | Older versions with more downloads are likely more stable and secure 63 | ![image](https://user-images.githubusercontent.com/17691679/193410588-aa858192-7c17-447a-825c-a2a8e55cf15b.png) 64 | 65 | #### Wait a few days before showing updates 66 | 67 | ![image](https://user-images.githubusercontent.com/17691679/193410812-78bfeb0f-02a5-41f5-8632-c1c682b85830.png) 68 | 69 | #### View code changes between versions of a plugin 70 | 71 | Clicking *Code Changes* will bring you to a page like https://github.com/blacksmithgu/obsidian-dataview/compare/0.5.43...0.5.46#files_bucket. 72 | 73 | ⚠️ The code in the git diff may be different than what's installed. Obsidian downloads a separate `main.js` file from the github release, which the author could add any code to. 74 | 75 | ## Ignore specific plugin updates 76 | Hide new plugin versions that you don't want to install from the plugin icon count and update list: 77 | 78 | https://user-images.githubusercontent.com/17691679/200182586-c0a237ff-3cf4-4693-b1c5-9051b599e1ae.mov 79 | 80 | ## View and install new beta plugin versions 81 | 82 | By default, new beta versions of plugins are hidden, but can be shown by changing this setting: 83 | 84 | ![image](https://user-images.githubusercontent.com/17691679/206864440-099b5dbe-4bad-4040-8312-01c8fa195c9b.png) 85 | 86 | A warning will be shown for beta version updates: 87 | 88 | ![image](https://user-images.githubusercontent.com/17691679/206864663-1392fdcd-d325-4ddb-8831-c6436988c8e3.png) 89 | 90 | ## Customizing plugin appearance 91 | 92 | ### Built-in 93 | 94 | The following built-in settings exist: 95 | 96 | ![image](https://github.com/swar8080/obsidian-plugin-update-tracker/assets/17691679/562161cd-5eca-4f71-817a-417992d2e9b4) 97 | 98 | ### Custom CSS Snippets 99 | 100 | Appearance can also be customized using obsidian CSS snippets (Go to *Settings > Appearance > CSS Snippets*) 101 | 102 | The following CSS selectors exist: 103 | 104 | - *.status-bar-item.plugin-obsidian-plugin-update-tracker*: The container of the status bar plugin icon 105 | 106 | - *.status-bar-item .plugin-update-tracker-icon--loading*: Plugin icon container in the loading state (⌛) 107 | 108 | - *.status-bar-item .plugin-update-tracker-icon--no-updates-available*: Plugin icon container when no updates are available (✓) 109 | 110 | - *.status-bar-item .plugin-update-tracker-icon--updates-available*: Plugin icon container when updates are available 111 | 112 | - *.status-bar-item .plugin-update-tracker-icon--error*: Plugin icon container when there's an error checking for updates (![image](https://github.com/swar8080/obsidian-plugin-update-tracker/assets/17691679/9f7d156b-c54c-4a3d-9e81-97aadf5dc68e)) 113 | 114 | 115 | - *.plugin-update-tracker-icon-plug-icon*: The plug icon (image 116 | ) 117 | 118 | - *.plugin-update-tracker-icon-chip*: The status icon to the right of the plug icon 119 | 120 | **Examples** 121 | 122 | Position the icon at the end of the status bar: 123 | ``` 124 | .status-bar-item.plugin-obsidian-plugin-update-tracker { 125 | order: 101; 126 | } 127 | ``` 128 | Position the icon at the beginning of the status bar: 129 | ``` 130 | .status-bar-item.plugin-obsidian-plugin-update-tracker { 131 | order: -1; 132 | } 133 | ``` 134 | 135 | # Using the public API to check for plugin updates 136 | 137 | The API used to get plugin version info is free for anyone to use. This could be helpful for alerting your plugin's users about updates in a custom way. 138 | 139 | Cacheing + AWS Lambda is used to keep costs low, avoid hitting github rate limits, and scale automatically. Currently, cached values are used for up to `Math.ceil(number of requested plugins / 50) * 30` minutes and up to 400 plugins are processed. However, a large number of cache misses can still cause high latency. 140 | 141 | 142 | Example Request: 143 | 144 | ``` 145 | POST https://jc5gpa3gs7o2uge6iq5mgjd53q0daomi.lambda-url.us-east-1.on.aws 146 | 147 | { 148 | "currentPluginVersions": [ 149 | { 150 | "obsidianPluginId": "dataview", 151 | "version": "0.5.44" 152 | }, 153 | { 154 | "obsidianPluginId": "obsidian-excalidraw-plugin", 155 | "version": "1.7.25" 156 | } 157 | ] 158 | } 159 | ``` 160 | 161 | Example Response, which contains info on the 10 latest versions of the plugin that are greater than the version requested: 162 | ``` 163 | [ 164 | { 165 | "obsidianPluginId": "dataview", 166 | "pluginName": "Dataview", 167 | "pluginRepositoryUrl": "https://github.com/blacksmithgu/obsidian-dataview", 168 | "pluginRepoPath": "blacksmithgu/obsidian-dataview", 169 | "newVersions": [ 170 | { 171 | "releaseId": 79493021, 172 | "versionName": "0.5.47", 173 | "versionNumber": "0.5.47", 174 | "minObsidianAppVersion": "0.13.11", 175 | "notes": "# 0.5.47\n\nImproves `date + duration` behavior when either the date or duration are null.\n", 176 | "areNotesTruncated": false, 177 | "downloads": 45232, 178 | "fileAssetIds": { 179 | "manifestJson": 80616557, 180 | "mainJs": 80616558 181 | }, 182 | "publishedAt": "2022-10-11T06:24:26Z", 183 | "updatedAt": "2022-10-11T06:24:26Z" 184 | }, 185 | { 186 | "releaseId": 76774596, 187 | "versionName": "0.5.46", 188 | "versionNumber": "0.5.46", 189 | "minObsidianAppVersion": "0.13.11", 190 | "notes": "# 0.5.46\n\n- Fix #1412: Fix bad `file.cday` and `file.ctime` comparisons due to wrong timezone being set. Ugh.\n", 191 | "areNotesTruncated": false, 192 | "downloads": 43628, 193 | "fileAssetIds": { 194 | "manifestJson": 77407853, 195 | "mainJs": 77407852 196 | }, 197 | "publishedAt": "2022-09-10T00:17:38Z", 198 | "updatedAt": "2022-09-10T00:17:38Z" 199 | }, 200 | { 201 | "releaseId": 76553504, 202 | "versionName": "0.5.45", 203 | "versionNumber": "0.5.45", 204 | "minObsidianAppVersion": "0.13.11", 205 | "notes": "# 0.5.45\n\n- #1400: Properly use the group by field for the group name.\n- Fix bad table highlighting in some themes.\n", 206 | "areNotesTruncated": false, 207 | "downloads": 4199, 208 | "fileAssetIds": { 209 | "manifestJson": 77205985, 210 | "mainJs": 77205984 211 | }, 212 | "publishedAt": "2022-09-08T05:24:23Z", 213 | "updatedAt": "2022-09-08T05:24:23Z" 214 | } 215 | ] 216 | }, 217 | { 218 | "obsidianPluginId": "obsidian-excalidraw-plugin", 219 | "pluginName": "Excalidraw", 220 | "pluginRepositoryUrl": "https://github.com/zsviczian/obsidian-excalidraw-plugin", 221 | "pluginRepoPath": "zsviczian/obsidian-excalidraw-plugin", 222 | "newVersions": [ 223 | { 224 | "releaseId": 80829877, 225 | "versionName": "Excalidraw 1.7.26", 226 | "versionNumber": "1.7.26", 227 | "minObsidianAppVersion": "0.15.6", 228 | "notes": "## Fixed\r\n- Transcluded block with a parent bullet does not embed sub-bullet [#853](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/853)\r\n- Transcluded text will now exclude ^block-references at end of lines\r\n- Phantom duplicates of the drawing appear when \"zoom to fit\" results in a zoom value below 10% and there are many objects on the canvas [#850](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/850)\r\n- CTRL+Wheel will increase/decrease zoom in steps of 5% matching the behavior of the \"+\" & \"-\" zoom buttons.\r\n- Latest updates from Excalidarw.com\r\n - Freedraw flip not scaling correctly [#5752](https://github.com/excalidraw/excalidraw/pull/5752)\r\n - Multiple elements resizing regressions [#5586](https://github.com/excalidraw/excalidraw/pull/5586)\r\n\r\n## New - power user features\r\n- Force the embedded image to always scale to 100%. Note: this is a very niche feature with a very particular behavior that I built primarily for myself (even more so than other features in Excalidraw Obsidian - also built primarily for myself 😉)... This will reset your embedded image to 100% size every time you open the Excalidraw drawing, or in case you have embedded an Excalidraw drawing on your canvas inserted using this function, every time you update the embedded drawing, it will be scaled back to 100% size. This means that even if you resize the image on the drawing, it will reset to 100% the next time you open the file or you modify the original embedded object. This feature is useful when you decompose a drawing into separate Excalidraw files, but when combined onto a single canvas you want the individual pieces to maintain their actual sizes. I use this feature to construct Book-on-a-Page summaries from atomic drawings.\r\n- I added an action to the command palette to temporarily disable/enable Excalidraw autosave. When autosave is disabled, Excalidraw will still save your drawing when changing to another Obsidian window, but it will not save every 10 seconds. On a mobile device (but also on a desktop) this can lead to data loss if you terminate Obsidian abruptly (i.e. swipe the application away, or close Obsidian without first closing the drawing). Use this feature if you find Excalidraw laggy.", 229 | "areNotesTruncated": false, 230 | "downloads": 877, 231 | "fileAssetIds": { 232 | "manifestJson": 82708877, 233 | "mainJs": 82708880 234 | }, 235 | "publishedAt": "2022-10-29T12:36:26Z", 236 | "updatedAt": "2022-10-29T12:36:10Z" 237 | } 238 | ] 239 | } 240 | ] 241 | ``` 242 | 243 | ## API Privacy 244 | The goal is sending the API only information needed for the plugin to function. Currently that's your list of installed plugins and their versions. AWS also automatically collects your IP which I have access to, and have no way of disabling. 245 | 246 | Any changes in what's collected will be included in release notes, but it's unlikely to change. 247 | 248 | # Usage with lazy-plugin 249 | 250 | When combining the *Ignore Updates to Disabled Plugins* setting with lazy-plugin, ensure that this plugin is loaded with the longest delay or else it may incorrectly identify another plugin as disabled. 251 | 252 | Also, when this plugin detects that lazy-plugin and *Ignore Updates to Disabled Plugins* are enabled, this plugin will remain in the loading state for an extra 10 seconds before actually checking for updates. This helps avoid the issue of incorrectly considering a plugin disabled. -------------------------------------------------------------------------------- /backend/.env: -------------------------------------------------------------------------------- 1 | OPUC_MAX_PLUGIN_COUNT_PROCESSED=400 2 | OPUC_RELEASES_CACHE_LENGTH_SECONDS_MULTIPLIER=3600 3 | OPUC_PLUGIN_CACHE_LENGTH_DIVISOR=75 4 | OPUC_RELEASES_FETCHED_PER_PLUGIN=10 5 | OPUC_MAX_RELEASE_NOTE_LENGTH=15000 6 | OPUC_MAX_MANIFESTS_TO_FETCH_PER_PLUGIN=10 7 | OPUC_GITHUB_API_TIMEOUT_MS=5000 8 | OPUC_IGNORE_RELEASES_FOR_THIS_PLUGIN=false 9 | 10 | #Defined in .env.secrets 11 | OPUC_GITHUB_ACCESS_TOKEN= 12 | OPUC_REDIS_URL=optional 13 | OPUC_REDIS_PASSWORD=optional 14 | 15 | #Automatically injected based on deployment but can be defined in .env.dev for local development 16 | OPUC_PLUGINS_LIST_BUCKET_NAME= 17 | OPUC_PLUGIN_RELEASES_TABLE_NAME= 18 | OPUC_METRIC_NAMESPACE= 19 | 20 | OPUC_USE_DYNAMODB_RELEASE_REPOSITORY=true 21 | OPUC_USE_REDIS_RELEASE_REPOSITORY=true -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | !jest.config.js 2 | *.d.ts 3 | node_modules 4 | 5 | # CDK asset staging directory 6 | .cdk.staging 7 | cdk.out 8 | 9 | .env.secrets 10 | .env.dev -------------------------------------------------------------------------------- /backend/aws/env.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import * as path from 'path'; 3 | 4 | type Deployment = { 5 | env: 'dev' | 'stage' | 'prod'; 6 | isDev: boolean; 7 | isProd: boolean; 8 | }; 9 | 10 | export function initEnv(): Deployment { 11 | dotenv.config({ path: path.resolve(__dirname, '../.env'), override: true }); 12 | dotenv.config({ path: path.resolve(__dirname, '../.env.secrets'), override: true }); 13 | 14 | if ( 15 | process.env.OPUC_ENV !== 'dev' && 16 | process.env.OPUC_ENV !== 'stage' && 17 | process.env.OPUC_ENV !== 'prod' 18 | ) { 19 | throw new Error('OPUC_ENV must be one of: dev, prod'); 20 | } 21 | 22 | if (process.env.OPUC_ENV === 'dev') { 23 | dotenv.config({ path: path.resolve(__dirname, '../.env.dev'), override: true }); 24 | } 25 | 26 | return { 27 | env: process.env.OPUC_ENV, 28 | isDev: process.env.OPUC_ENV === 'dev', 29 | isProd: process.env.OPUC_ENV === 'prod', 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /backend/aws/obsidian-plugin-update-checker-resources.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import * as s3 from 'aws-cdk-lib/aws-s3'; 4 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 5 | import * as logs from 'aws-cdk-lib/aws-logs'; 6 | import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; 7 | import * as iam from 'aws-cdk-lib/aws-iam'; 8 | import { NodejsFunction, LogLevel } from 'aws-cdk-lib/aws-lambda-nodejs'; 9 | import * as path from 'path'; 10 | import { Duration } from 'aws-cdk-lib'; 11 | import { initEnv } from './env'; 12 | 13 | const deployment = initEnv(); 14 | 15 | export class ObsidianPluginUpdaterStack extends cdk.Stack { 16 | constructor(scope: Construct, id: string, namespace: string, props?: cdk.StackProps) { 17 | super(scope, id, props); 18 | 19 | const getReleasesLambda = new NodejsFunction(this, `get-releases-function`, { 20 | functionName: `get-releases-function-${deployment.env}`, 21 | entry: path.join(__dirname, '../get-releases-lambda/src/handler.ts'), 22 | handler: 'main', 23 | memorySize: 150, 24 | timeout: Duration.seconds(120), 25 | reservedConcurrentExecutions: deployment.isProd ? 40 : 1, 26 | runtime: lambda.Runtime.NODEJS_20_X, 27 | architecture: lambda.Architecture.ARM_64, 28 | environment: { 29 | ...getFromEnvironmentVariables([ 30 | 'OPUC_MAX_PLUGIN_COUNT_PROCESSED', 31 | 'OPUC_GITHUB_ACCESS_TOKEN', 32 | 'OPUC_RELEASES_CACHE_LENGTH_SECONDS_MULTIPLIER', 33 | 'OPUC_PLUGIN_CACHE_LENGTH_DIVISOR', 34 | 'OPUC_RELEASES_FETCHED_PER_PLUGIN', 35 | 'OPUC_MAX_RELEASE_NOTE_LENGTH', 36 | 'OPUC_MAX_MANIFESTS_TO_FETCH_PER_PLUGIN', 37 | 'OPUC_GITHUB_API_TIMEOUT_MS', 38 | 'OPUC_IGNORE_RELEASES_FOR_THIS_PLUGIN', 39 | 'OPUC_USE_DYNAMODB_RELEASE_REPOSITORY', 40 | 'OPUC_USE_REDIS_RELEASE_REPOSITORY', 41 | 'OPUC_REDIS_URL', 42 | 'OPUC_REDIS_PASSWORD', 43 | 'OPUC_SALT', 44 | ]), 45 | OPUC_IS_PROD: deployment.isProd.toString(), 46 | OPUC_DEBUG_LOGS_ENABLED: deployment.isDev.toString(), 47 | }, 48 | bundling: { 49 | minify: false, 50 | keepNames: true, 51 | logLevel: LogLevel.INFO, 52 | }, 53 | logRetention: deployment.isProd 54 | ? logs.RetentionDays.TWO_WEEKS 55 | : logs.RetentionDays.THREE_DAYS, 56 | }); 57 | 58 | const getReleasesFunctionUrl = getReleasesLambda.addFunctionUrl({ 59 | authType: lambda.FunctionUrlAuthType.NONE, 60 | }); 61 | new cdk.CfnOutput(this, 'ReminderPromptUrl', { 62 | value: getReleasesFunctionUrl.url, 63 | }); 64 | 65 | /** 66 | * S3 bucket storing https://github.com/obsidianmd/obsidian-releases/blob/master/community-plugins.json. 67 | * That file is cached for a day before being deleted, at which point the lambda should fetch and write the file. 68 | */ 69 | const pluginsListBucket = new s3.Bucket(this, `plugins-list-bucket`, { 70 | bucketName: `plugin-list-bucket-${deployment.env}-${namespace}`, 71 | lifecycleRules: [ 72 | { 73 | expiration: Duration.days(1), 74 | }, 75 | ], 76 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 77 | }); 78 | getReleasesLambda.addEnvironment( 79 | 'OPUC_PLUGINS_LIST_BUCKET_NAME', 80 | pluginsListBucket.bucketName 81 | ); 82 | pluginsListBucket.grantRead(getReleasesLambda); 83 | pluginsListBucket.grantPut(getReleasesLambda); 84 | 85 | //Table used by lambda for cacheing plugin release metadata 86 | const pluginReleaseTable = new dynamodb.Table(this, 'plugin-releases-table', { 87 | tableName: `plugin-releases-table-${deployment.env}`, 88 | partitionKey: { 89 | name: 'pluginId', 90 | type: dynamodb.AttributeType.STRING, 91 | }, 92 | billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, 93 | removalPolicy: deployment.isProd ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, 94 | }); 95 | getReleasesLambda.addEnvironment( 96 | 'OPUC_PLUGIN_RELEASES_TABLE_NAME', 97 | pluginReleaseTable.tableName 98 | ); 99 | pluginReleaseTable.grantReadData(getReleasesLambda); 100 | pluginReleaseTable.grantWriteData(getReleasesLambda); 101 | 102 | //Allow the lambda to write to metrics in its namespace 103 | const metricNamespace = `obsidian-plugin-update-checker-prod`; 104 | const putMetricsPolicy = new iam.PolicyStatement({ 105 | actions: ['cloudwatch:PutMetricData'], 106 | effect: iam.Effect.ALLOW, 107 | resources: ['*'], 108 | }); 109 | putMetricsPolicy.addCondition('StringEquals', { 'cloudwatch:namespace': metricNamespace }); 110 | getReleasesLambda.addToRolePolicy(putMetricsPolicy); 111 | getReleasesLambda.addEnvironment('OPUC_METRIC_NAMESPACE', metricNamespace); 112 | } 113 | } 114 | 115 | function getFromEnvironmentVariables(names: string[]) { 116 | return names.reduce((combined, name) => { 117 | if (process.env[name] == null || process.env[name] === '') { 118 | throw new Error(`Environment variable ${name} is not defined`); 119 | } 120 | const value = process.env[name] as string; 121 | combined[name] = value; 122 | return combined; 123 | }, {} as Record); 124 | } 125 | -------------------------------------------------------------------------------- /backend/aws/stacks.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import * as cdk from 'aws-cdk-lib'; 3 | import 'source-map-support/register'; 4 | import { ObsidianPluginUpdaterStack } from './obsidian-plugin-update-checker-resources'; 5 | 6 | if (!process.env.OPUC_RESOURCE_NAMESPACE) { 7 | //needed for globally unique s3 bucket names 8 | throw new Error('OPUC_RESOURCE_NAMESPACE environment variable must be defined'); 9 | } 10 | 11 | const app = new cdk.App(); 12 | new ObsidianPluginUpdaterStack( 13 | app, 14 | 'obsidian-plugin-update-checker-dev', 15 | process.env.OPUC_RESOURCE_NAMESPACE, 16 | {} 17 | ); 18 | new ObsidianPluginUpdaterStack( 19 | app, 20 | 'obsidian-plugin-update-checker-prod', 21 | process.env.OPUC_RESOURCE_NAMESPACE, 22 | {} 23 | ); 24 | -------------------------------------------------------------------------------- /backend/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts aws/stacks.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 21 | "@aws-cdk/core:stackRelativeExports": true, 22 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 23 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 24 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 25 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/core:checkSecretUsage": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 31 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 32 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 33 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 34 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 35 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 36 | "@aws-cdk/core:enablePartitionLiterals": true, 37 | "@aws-cdk/core:target-partitions": [ 38 | "aws", 39 | "aws-cn" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/.gitignore: -------------------------------------------------------------------------------- 1 | src/*.js -------------------------------------------------------------------------------- /backend/get-releases-lambda/local/.gitignore: -------------------------------------------------------------------------------- 1 | custom-requests/* -------------------------------------------------------------------------------- /backend/get-releases-lambda/local/local.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import { main } from '../src/handler'; 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | 6 | dotenv.config({ path: path.resolve(__dirname, '../../.env'), override: true }); 7 | dotenv.config({ path: path.resolve(__dirname, '../../.env.secrets'), override: true }); 8 | dotenv.config({ path: path.resolve(__dirname, '../../.env.dev'), override: true }); 9 | 10 | const awsResourceNamespace = process.env['OPUC_RESOURCE_NAMESPACE']; 11 | process.env['OPUC_PLUGINS_LIST_BUCKET_NAME'] = `plugin-list-bucket-dev-${awsResourceNamespace}`; 12 | process.env['OPUC_PLUGIN_RELEASES_TABLE_NAME'] = 'plugin-releases-table-dev'; 13 | process.env['OPUC_METRIC_NAMESPACE'] = 'obsidian-plugin-update-checker-dev'; 14 | process.env['OPUC_IS_PROD'] = 'false'; 15 | 16 | const requestFile = process.env['OPUC_LOCAL_REQUEST_FILE'] || 'single-plugin-request.json'; 17 | const requestFileContents = fs.readFileSync(path.resolve(__dirname, requestFile)); 18 | 19 | const apiGatewayRequest = { 20 | body: requestFileContents, 21 | requestContext: { 22 | http: { 23 | method: 'POST', 24 | }, 25 | }, 26 | }; 27 | 28 | //@ts-ignore 29 | main(apiGatewayRequest) 30 | .then((res) => console.log(JSON.stringify(res))) 31 | .catch(console.error); 32 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/local/single-plugin-request.json: -------------------------------------------------------------------------------- 1 | { 2 | "currentPluginVersions": [ 3 | { 4 | "obsidianPluginId": "obsidian-discordrpc", 5 | "version": "1.1.0" 6 | } 7 | ] 8 | } -------------------------------------------------------------------------------- /backend/get-releases-lambda/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "get-releases", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "local": "tsc --watch local/local.ts" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@aws-sdk/client-cloudwatch": "^3.171.0", 13 | "@aws-sdk/client-dynamodb": "^3.171.0", 14 | "@aws-sdk/client-lambda": "^3.171.0", 15 | "@aws-sdk/client-s3": "^3.171.0", 16 | "@aws-sdk/lib-dynamodb": "^3.171.0", 17 | "axios": "^0.27.2", 18 | "dayjs": "^1.11.5", 19 | "redis": "^4.3.1" 20 | }, 21 | "devDependencies": { 22 | "@types/aws-lambda": "^8.10.104" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/src/MetricLogger.ts: -------------------------------------------------------------------------------- 1 | import { CloudWatchClient, Dimension, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch'; 2 | 3 | export interface MetricLogger { 4 | trackGithubRateLimit(rateLimt: number): Promise; 5 | trackErrorCodeOccurrence(errorCode: ErrorCode): Promise; 6 | } 7 | 8 | type ErrorCode = 9 | | 'GITHUB_FETCH_RELEASES' 10 | | 'GITHUB_FETCH_MANIFEST' 11 | | 'GITHUB_FETCH_MASTER_MANIFEST' 12 | | 'GITHUB_FETCH_PLUGIN_LIST' 13 | | 'REDIS_CONNECTION_ERROR' 14 | | 'REDIS_FETCH_RELEASE_RECORDS' 15 | | 'REDIS_PERSIST_RELEASE_RECORDS' 16 | | 'S3_FETCH_PLUGIN_LIST' 17 | | 'S3_PUT_PLUGIN_LIST'; 18 | 19 | const GITHUB_RATE_LIMIT_METRIC_NAME = 'Github Rate Limit'; 20 | const ERROR_CODE_METRIC_NAME = 'Error Codes'; 21 | 22 | export class CloudWatchMetricLogger implements MetricLogger { 23 | private metricNamespace: string; 24 | private cloudwatch: CloudWatchClient; 25 | private metricBuffer = { 26 | [GITHUB_RATE_LIMIT_METRIC_NAME]: { 27 | bufferedRequests: 0, 28 | bufferedValue: 0, 29 | }, 30 | }; 31 | 32 | constructor(metricNamespace: string) { 33 | this.metricNamespace = metricNamespace; 34 | this.cloudwatch = new CloudWatchClient({}); 35 | } 36 | 37 | public async trackGithubRateLimit(rateLimt: number): Promise { 38 | this.metricBuffer[GITHUB_RATE_LIMIT_METRIC_NAME].bufferedValue = rateLimt; 39 | 40 | const bufferedRequests = ++this.metricBuffer[GITHUB_RATE_LIMIT_METRIC_NAME] 41 | .bufferedRequests; 42 | if (bufferedRequests >= 20) { 43 | await this.flush(); 44 | } 45 | } 46 | 47 | public async trackErrorCodeOccurrence(errorCode: ErrorCode): Promise { 48 | try { 49 | const errorCodeDimension: Dimension = { 50 | Name: 'ErrorCode', 51 | Value: errorCode, 52 | }; 53 | await this.putMetric(ERROR_CODE_METRIC_NAME, 1, [errorCodeDimension]); 54 | } catch (err) { 55 | console.error(`Error tracking errorCode ${errorCode}`, err); 56 | } 57 | } 58 | 59 | public async flush() { 60 | const { bufferedRequests, bufferedValue } = 61 | this.metricBuffer[GITHUB_RATE_LIMIT_METRIC_NAME]; 62 | if (bufferedRequests > 0) { 63 | try { 64 | await this.putMetric(GITHUB_RATE_LIMIT_METRIC_NAME, bufferedValue); 65 | this.metricBuffer[GITHUB_RATE_LIMIT_METRIC_NAME].bufferedRequests = 0; 66 | } catch (err) { 67 | console.error(`Error putting metric ${GITHUB_RATE_LIMIT_METRIC_NAME}`, err); 68 | throw err; 69 | } 70 | } 71 | } 72 | 73 | private async putMetric( 74 | name: string, 75 | value: number, 76 | dimensions: Dimension[] | undefined = undefined 77 | ) { 78 | await this.cloudwatch.send( 79 | new PutMetricDataCommand({ 80 | Namespace: this.metricNamespace, 81 | MetricData: [ 82 | { 83 | MetricName: name, 84 | Value: value, 85 | Timestamp: new Date(), 86 | Dimensions: dimensions, 87 | }, 88 | ], 89 | }) 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/src/PluginRepository.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { GetObjectCommand, S3Client, NoSuchKey, PutObjectCommand } from '@aws-sdk/client-s3'; 3 | import { groupById } from './util'; 4 | import * as streamConsumers from 'stream/consumers'; 5 | import { MetricLogger } from './MetricLogger'; 6 | 7 | export interface PluginRepository { 8 | getPluginsById(pluginIds: string[]): Promise>; 9 | } 10 | 11 | export type PluginRecord = { 12 | id: string; 13 | repo: string; 14 | name: string; 15 | }; 16 | 17 | const PLUGINS_LIST_GITHUB_URL = 18 | 'https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugins.json'; 19 | const PLUGIN_LIST_S3_KEY_NAME = 'community-plugins.json'; 20 | 21 | let _cachedPluginRecords: Record | null = null; 22 | 23 | export class S3PluginRepository implements PluginRepository { 24 | private bucketName: string; 25 | private s3: S3Client; 26 | private metricLogger: MetricLogger; 27 | 28 | constructor(bucketName: string, metricLogger: MetricLogger) { 29 | this.bucketName = bucketName; 30 | this.s3 = new S3Client({}); 31 | this.metricLogger = metricLogger; 32 | } 33 | 34 | async getPluginsById(pluginIds: string[]): Promise> { 35 | const fullPluginMap = await this.getFullPluginMap(); 36 | 37 | return pluginIds 38 | .filter((pluginId) => pluginId in fullPluginMap) 39 | .reduce((combined, pluginId) => { 40 | combined[pluginId] = fullPluginMap[pluginId]; 41 | return combined; 42 | }, {} as Record); 43 | } 44 | 45 | private async getFullPluginMap(): Promise> { 46 | if (_cachedPluginRecords != null) { 47 | return _cachedPluginRecords; 48 | } 49 | 50 | let pluginsFile: PluginRecord[]; 51 | try { 52 | pluginsFile = await this.fetchFromS3(); 53 | } catch (e) { 54 | if (e instanceof NoSuchKey) { 55 | console.log( 56 | `${PLUGIN_LIST_S3_KEY_NAME} has expired from ${this.bucketName}, will re-fetch and store it` 57 | ); 58 | pluginsFile = await this.fetchFromGithub(); 59 | await this.tryStoringInS3(pluginsFile); 60 | } else { 61 | console.error( 62 | `Unexpected Error fetching ${PLUGIN_LIST_S3_KEY_NAME} from ${this.bucketName}`, 63 | e 64 | ); 65 | this.metricLogger.trackErrorCodeOccurrence('S3_FETCH_PLUGIN_LIST'); 66 | throw e; 67 | } 68 | } 69 | 70 | _cachedPluginRecords = groupById(pluginsFile, 'id'); 71 | return _cachedPluginRecords; 72 | } 73 | 74 | private async fetchFromS3(): Promise { 75 | const getObjectCommand = new GetObjectCommand({ 76 | Bucket: this.bucketName, 77 | Key: PLUGIN_LIST_S3_KEY_NAME, 78 | }); 79 | 80 | const getObjectResponse = await this.s3.send(getObjectCommand); 81 | 82 | if (getObjectResponse.Body) { 83 | const stream: NodeJS.ReadableStream = getObjectResponse.Body as NodeJS.ReadableStream; 84 | const jsonResponse = await streamConsumers.text(stream); 85 | return JSON.parse(jsonResponse); 86 | } else { 87 | console.error('Unexpected S3 GetObject response', getObjectResponse); 88 | throw new Error(); 89 | } 90 | } 91 | 92 | private async fetchFromGithub(): Promise { 93 | try { 94 | return await axios({ 95 | method: 'get', 96 | url: PLUGINS_LIST_GITHUB_URL, 97 | }).then((res) => res.data); 98 | } catch (err) { 99 | console.warn('Error fetching plugin list from github', err); 100 | this.metricLogger.trackErrorCodeOccurrence('GITHUB_FETCH_PLUGIN_LIST'); 101 | throw err; 102 | } 103 | } 104 | 105 | private async tryStoringInS3(pluginsFile: PluginRecord[]): Promise { 106 | try { 107 | const putObjectCommand = new PutObjectCommand({ 108 | Bucket: this.bucketName, 109 | Key: PLUGIN_LIST_S3_KEY_NAME, 110 | Body: JSON.stringify(pluginsFile), 111 | }); 112 | await this.s3.send(putObjectCommand); 113 | } catch (e) { 114 | console.warn(`Unable to persist ${PLUGIN_LIST_S3_KEY_NAME} in ${this.bucketName}`, e); 115 | this.metricLogger.trackErrorCodeOccurrence('S3_PUT_PLUGIN_LIST'); 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/src/ReleaseApi.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError, AxiosResponse } from 'axios'; 2 | import { MetricLogger } from './MetricLogger'; 3 | 4 | export interface ReleaseApi { 5 | fetchReleases( 6 | repositoryPath: string, 7 | limit: number, 8 | etag?: string 9 | ): Promise; 10 | 11 | fetchReleaseManifest(repositoryPath: string, assetId: number): Promise; 12 | 13 | fetchMasterManifest( 14 | repositoryPath: string, 15 | etag: string | undefined 16 | ): Promise; 17 | } 18 | 19 | export class GithubReleaseApi implements ReleaseApi { 20 | private githubAccessToken: string; 21 | private metricLogger: MetricLogger; 22 | private timeoutMs: number; 23 | 24 | constructor(githubAccessToken: string, metricLogger: MetricLogger, timeoutMs: number) { 25 | this.githubAccessToken = githubAccessToken; 26 | this.metricLogger = metricLogger; 27 | this.timeoutMs = timeoutMs; 28 | } 29 | 30 | async fetchReleases( 31 | repositoryPath: string, 32 | limit: number, 33 | etag?: string | undefined 34 | ): Promise { 35 | //fetch more than needed because draft/prereleases may be included in the results 36 | const per_page = Math.min(100, limit * 2); 37 | 38 | try { 39 | const response = await axios({ 40 | method: 'get', 41 | url: `https://api.github.com/repos/${repositoryPath}/releases?per_page=${per_page}`, 42 | headers: { 43 | Authorization: `Bearer ${this.githubAccessToken}`, 44 | Accept: 'application/vnd.github+json', 45 | 'If-None-Match': etag || '', 46 | }, 47 | timeout: this.timeoutMs, 48 | }); 49 | 50 | this.emitRateLimitMetric(response); 51 | 52 | const releases: ApiReleases[] = response.data; 53 | const filteredReleases = releases.filter((release) => !release.draft).slice(0, limit); 54 | 55 | return { 56 | hasChanges: true, 57 | releases: filteredReleases, 58 | etag: response.headers['etag'], 59 | }; 60 | } catch (err) { 61 | if (err instanceof AxiosError && err.response?.status === 304) { 62 | return { 63 | hasChanges: false, 64 | }; 65 | } 66 | console.error(`Unexpected error fetching github releases for ${repositoryPath}`, err); 67 | await this.metricLogger.trackErrorCodeOccurrence('GITHUB_FETCH_RELEASES'); 68 | throw err; 69 | } 70 | } 71 | 72 | async fetchReleaseManifest( 73 | repositoryPath: string, 74 | assetId: number 75 | ): Promise { 76 | try { 77 | const response = await axios({ 78 | method: 'get', 79 | url: `https://api.github.com/repos/${repositoryPath}/releases/assets/${assetId}`, 80 | headers: { 81 | Authorization: `Bearer ${this.githubAccessToken}`, 82 | Accept: 'application/octet-stream', 83 | }, 84 | timeout: this.timeoutMs, 85 | }); 86 | this.emitRateLimitMetric(response); 87 | 88 | return response.data; 89 | } catch (err) { 90 | this.metricLogger.trackErrorCodeOccurrence('GITHUB_FETCH_MANIFEST'); 91 | throw err; 92 | } 93 | } 94 | 95 | async fetchMasterManifest( 96 | repositoryPath: string, 97 | etag: string | undefined 98 | ): Promise { 99 | try { 100 | const response = await axios({ 101 | method: 'get', 102 | url: `https://api.github.com/repos/${repositoryPath}/contents/manifest.json`, 103 | headers: { 104 | Authorization: `Bearer ${this.githubAccessToken}`, 105 | 'If-None-Match': etag || '', 106 | }, 107 | timeout: this.timeoutMs, 108 | }); 109 | this.emitRateLimitMetric(response); 110 | 111 | const base64Content: string = response.data.content; 112 | const base64Buffer = Buffer.from(base64Content, 'base64'); 113 | const manifest = JSON.parse(base64Buffer.toString('utf-8')); 114 | 115 | return { 116 | hasChanges: true, 117 | manifest, 118 | etag: response.headers['etag'], 119 | }; 120 | } catch (err) { 121 | if (err instanceof AxiosError && err.response?.status === 304) { 122 | return { 123 | hasChanges: false, 124 | }; 125 | } 126 | console.error(`Unexpected error fetching master manifest for ${repositoryPath}`, err); 127 | await this.metricLogger.trackErrorCodeOccurrence('GITHUB_FETCH_MASTER_MANIFEST'); 128 | throw err; 129 | } 130 | } 131 | 132 | private emitRateLimitMetric(response: AxiosResponse) { 133 | const rateLimitRemaining = response.headers['x-ratelimit-remaining']; 134 | try { 135 | if (rateLimitRemaining != null) { 136 | this.metricLogger.trackGithubRateLimit(parseInt(rateLimitRemaining)); 137 | } 138 | } catch (err) { 139 | console.warn(`Error logging rate limit metric ${rateLimitRemaining}`, err); 140 | } 141 | } 142 | } 143 | 144 | export type ApiReleaseResponse = 145 | | { 146 | hasChanges: true; 147 | releases: ApiReleases[]; 148 | etag: string; 149 | } 150 | | { hasChanges: false }; 151 | 152 | export type ApiMasterPluginManifestResponse = 153 | | { 154 | hasChanges: true; 155 | manifest: ApiPluginManifest; 156 | etag: string; 157 | } 158 | | { hasChanges: false }; 159 | 160 | export type ApiReleases = { 161 | id: number; 162 | name: string; 163 | tag_name: string; 164 | prerelease: boolean; 165 | draft: boolean; 166 | published_at: string; 167 | body?: string; 168 | assets?: ApiReleaseAsset[]; 169 | }; 170 | 171 | type ApiReleaseAsset = { 172 | id: number; 173 | name: string; 174 | download_count: number; 175 | updated_at: string; 176 | }; 177 | 178 | export type ApiPluginManifest = { 179 | version?: string; 180 | minAppVersion?: string; 181 | }; 182 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/src/ReleaseRepository/DynamoDBReleaseRepository.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 2 | import { 3 | DynamoDBDocumentClient, 4 | BatchGetCommand, 5 | BatchGetCommandOutput, 6 | BatchWriteCommand, 7 | } from '@aws-sdk/lib-dynamodb'; 8 | import { PluginReleasesRecord, ReleaseRepository } from '.'; 9 | import { isEmpty, partition } from '../util'; 10 | 11 | const DDB_MAX_WRITE_BATCH_SIZE = 25; 12 | 13 | export class DynamoDBReleaseRepository implements ReleaseRepository { 14 | private tableName: string; 15 | private dynamodb: DynamoDBDocumentClient; 16 | 17 | constructor(tableName: string) { 18 | this.tableName = tableName; 19 | 20 | this.dynamodb = DynamoDBDocumentClient.from(new DynamoDBClient({}), { 21 | marshallOptions: { removeUndefinedValues: true }, 22 | }); 23 | } 24 | 25 | async getReleases(pluginIds: string[]): Promise { 26 | if (isEmpty(pluginIds)) { 27 | return []; 28 | } 29 | 30 | const command: BatchGetCommand = new BatchGetCommand({ 31 | RequestItems: { 32 | [this.tableName]: { 33 | Keys: pluginIds.map((pluginId) => ({ 34 | pluginId, 35 | })), 36 | }, 37 | }, 38 | }); 39 | const response: BatchGetCommandOutput = await this.dynamodb.send(command); 40 | 41 | if (response.Responses && this.tableName in response.Responses) { 42 | const rows: Record[] = response.Responses[this.tableName]; 43 | return rows as PluginReleasesRecord[]; 44 | } 45 | 46 | console.error('Unexpected empty response from dynamo', response); 47 | throw new Error(); 48 | } 49 | 50 | async save(records: PluginReleasesRecord[]): Promise { 51 | if (isEmpty(records)) { 52 | return; 53 | } 54 | 55 | const batches = partition(records, DDB_MAX_WRITE_BATCH_SIZE); 56 | 57 | for (const batch of batches) { 58 | const command = new BatchWriteCommand({ 59 | RequestItems: { 60 | [this.tableName]: batch.map((record) => ({ 61 | PutRequest: { 62 | Item: record, 63 | }, 64 | })), 65 | }, 66 | }); 67 | 68 | await this.dynamodb.send(command); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/src/ReleaseRepository/FallbackReleaseRepository.test.ts: -------------------------------------------------------------------------------- 1 | import { ReleaseRepository } from '.'; 2 | import { FallbackReleaseRepository } from './FallbackReleaseRepository'; 3 | 4 | describe('FallbackReleaseRepository', () => { 5 | let fallbackReleaseRepository: FallbackReleaseRepository; 6 | let faultyReleaseRepository: ReleaseRepository; 7 | let backupReleaseRepository: ReleaseRepository; 8 | 9 | beforeEach(() => { 10 | faultyReleaseRepository = { 11 | getReleases: jest.fn().mockRejectedValue(null), 12 | save: jest.fn().mockRejectedValue(null), 13 | }; 14 | backupReleaseRepository = { 15 | getReleases: jest.fn().mockResolvedValueOnce([]), 16 | save: jest.fn().mockResolvedValue(null), 17 | }; 18 | }); 19 | 20 | it('will fallback to the second repository and use that one next time', async () => { 21 | fallbackReleaseRepository = new FallbackReleaseRepository([ 22 | faultyReleaseRepository, 23 | backupReleaseRepository, 24 | ]); 25 | 26 | let result = await fallbackReleaseRepository.getReleases(['plugin1']); 27 | 28 | expect(faultyReleaseRepository.getReleases).toHaveBeenCalledTimes(1); 29 | expect(backupReleaseRepository.getReleases).toHaveBeenCalledTimes(1); 30 | expect(result).toEqual([]); 31 | 32 | result = await fallbackReleaseRepository.getReleases(['plugin1']); 33 | 34 | expect(faultyReleaseRepository.getReleases).toHaveBeenCalledTimes(1); 35 | expect(backupReleaseRepository.getReleases).toHaveBeenCalledTimes(2); 36 | }); 37 | 38 | it('will give up if all repositories fail', (done) => { 39 | fallbackReleaseRepository = new FallbackReleaseRepository([ 40 | faultyReleaseRepository, 41 | faultyReleaseRepository, 42 | ]); 43 | 44 | fallbackReleaseRepository.getReleases(['plugin1']).catch(() => { 45 | expect(faultyReleaseRepository.getReleases).toHaveBeenCalledTimes(2); 46 | done(); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/src/ReleaseRepository/FallbackReleaseRepository.ts: -------------------------------------------------------------------------------- 1 | import { PluginReleasesRecord, ReleaseRepository } from '.'; 2 | import { isEmpty } from '../util'; 3 | 4 | export class FallbackReleaseRepository implements ReleaseRepository { 5 | private releaseRepositories: ReleaseRepository[]; 6 | 7 | constructor(releaseRepositories: ReleaseRepository[]) { 8 | if (isEmpty(releaseRepositories)) { 9 | throw new Error('release repositories cannot be empty'); 10 | } 11 | 12 | this.releaseRepositories = releaseRepositories; 13 | } 14 | 15 | public async getReleases(pluginIds: string[]): Promise { 16 | return await this.doWithFallback( 17 | (releaseRepository) => releaseRepository.getReleases(pluginIds), 18 | 'getReleases' 19 | ); 20 | } 21 | 22 | public async save(records: PluginReleasesRecord[]): Promise { 23 | return await this.doWithFallback( 24 | (releaseRepository) => releaseRepository.save(records), 25 | 'save' 26 | ); 27 | } 28 | 29 | private async doWithFallback( 30 | operation: (releaseRepository: ReleaseRepository) => Promise, 31 | operationName: string 32 | ): Promise { 33 | let prioritizedOrder = this.releaseRepositories; 34 | try { 35 | for (let i = 0; i < this.releaseRepositories.length; i++) { 36 | const releaseRepository = this.releaseRepositories[i]; 37 | try { 38 | return await operation(releaseRepository); 39 | } catch (err) { 40 | console.error( 41 | `Error handling operation ${operationName} with release repository ${releaseRepository}`, 42 | err 43 | ); 44 | 45 | if (i + 1 >= this.releaseRepositories.length) { 46 | throw err; 47 | } 48 | 49 | //move to back of list of repositories to try for the next operations 50 | prioritizedOrder = [...prioritizedOrder]; 51 | prioritizedOrder.splice(i, 1); 52 | prioritizedOrder.push(releaseRepository); 53 | } 54 | } 55 | } finally { 56 | this.releaseRepositories = prioritizedOrder; 57 | } 58 | 59 | throw new Error('No release repositories configured'); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/src/ReleaseRepository/RedisReleaseRepository.ts: -------------------------------------------------------------------------------- 1 | import { PluginReleasesRecord, ReleaseRepository } from '.'; 2 | import { MetricLogger } from '../MetricLogger'; 3 | import { RedisClient } from '../redisClient'; 4 | 5 | const KEY_NAMESPACE = 'releases'; 6 | 7 | export class RedisReleaseRepository implements ReleaseRepository { 8 | private redisClient: RedisClient; 9 | private metricLogger: MetricLogger; 10 | 11 | constructor(redisClient: RedisClient, metricLogger: MetricLogger) { 12 | this.redisClient = redisClient; 13 | this.metricLogger = metricLogger; 14 | } 15 | 16 | public async getReleases(pluginIds: string[]): Promise { 17 | try { 18 | return await this.redisClient.getJson(KEY_NAMESPACE, pluginIds); 19 | } catch (err) { 20 | console.error('Error getting releases from redis', err); 21 | this.metricLogger.trackErrorCodeOccurrence('REDIS_FETCH_RELEASE_RECORDS'); 22 | throw err; 23 | } 24 | } 25 | 26 | public async save(records: PluginReleasesRecord[]): Promise { 27 | try { 28 | return await this.redisClient.putJson( 29 | KEY_NAMESPACE, 30 | records, 31 | (record) => record.pluginId 32 | ); 33 | } catch (err) { 34 | console.error('Error persisting releases to redis', err); 35 | this.metricLogger.trackErrorCodeOccurrence('REDIS_PERSIST_RELEASE_RECORDS'); 36 | throw err; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/src/ReleaseRepository/index.ts: -------------------------------------------------------------------------------- 1 | import { PluginFileAssetIds } from '../../../../oput-common'; 2 | 3 | export interface ReleaseRepository { 4 | //Sorted from most to least recent 5 | getReleases(pluginIds: string[]): Promise; 6 | save(records: PluginReleasesRecord[]): Promise; 7 | } 8 | 9 | export type PluginReleasesRecord = { 10 | pluginId: string; 11 | pluginRepo: string; 12 | 13 | releases: { 14 | id: number; 15 | versionName: string; 16 | versionNumber: string; 17 | notes: string; 18 | areNotesTruncated: boolean; 19 | downloads: number; 20 | publishedAt: string; 21 | sourceCodeUpdatedAt: string; 22 | 23 | fileAssetIds?: PluginFileAssetIds; 24 | manifestAssetId?: number; 25 | minObsidianVersion?: string; 26 | manifestLastUpdatedAt?: string; 27 | }[]; 28 | 29 | masterManifest?: MasterManifestInfo; 30 | 31 | lastFetchedFromGithub: string; 32 | lastFetchedETag: string; 33 | }; 34 | 35 | export type MasterManifestInfo = { 36 | versionNumber?: string; 37 | etag: string; 38 | }; 39 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/src/get-releases.test.ts: -------------------------------------------------------------------------------- 1 | import dayjs = require('dayjs'); 2 | import { 3 | InstalledPluginVersion, 4 | NewPluginVersionRequest, 5 | PluginReleases, 6 | } from '../../../oput-common'; 7 | import { GetReleases, GetReleasesConfiguration } from './get-releases'; 8 | import { PluginRecord, PluginRepository } from './PluginRepository'; 9 | import { ApiReleases, ReleaseApi } from './ReleaseApi'; 10 | import { PluginReleasesRecord, ReleaseRepository } from './ReleaseRepository'; 11 | 12 | const PLUGIN_REQUEST_BASE: InstalledPluginVersion = { obsidianPluginId: '', version: '' }; 13 | 14 | const PLUGIN_1_ID_REQUESTED = 'PLUGIN_1_ID'; 15 | const PLUGIN_1_ID = 'plugin_1_id'; 16 | const PLUGIN_1_INSTALLED_VERSION = '1.2.3'; 17 | const PLUGIN_1_REQUEST: InstalledPluginVersion = { 18 | ...PLUGIN_REQUEST_BASE, 19 | obsidianPluginId: PLUGIN_1_ID_REQUESTED, 20 | version: PLUGIN_1_INSTALLED_VERSION, 21 | }; 22 | const PLUGIN_1_REPO = 'author1/plugin1'; 23 | const PLUGIN_1_RECORD: PluginRecord = { id: PLUGIN_1_ID, repo: PLUGIN_1_REPO, name: 'Plugin 1' }; 24 | const PLUGIN_1_NEW_VERSION_NUM = '1.2.4'; 25 | 26 | const PLUGIN_2_ID_REQUESTED = 'PLUGIN_2_ID'; 27 | const PLUGIN_2_ID = 'plugin_2_id'; 28 | const PLUGIN_2_INSTALLED_VERSION = '3.4.5'; 29 | const PLUGIN_2_REPO = 'author2/plugin2'; 30 | const PLUGIN_2_REQUEST: InstalledPluginVersion = { 31 | ...PLUGIN_REQUEST_BASE, 32 | obsidianPluginId: PLUGIN_2_ID_REQUESTED, 33 | version: PLUGIN_2_INSTALLED_VERSION, 34 | }; 35 | const PLUGIN_2_RECORD: PluginRecord = { id: PLUGIN_2_ID, repo: PLUGIN_2_REPO, name: 'Plugin 2' }; 36 | 37 | const UNKNOWN_PLUGIN_ID = 'unknown'; 38 | const UNKNOWN_PLUGIN_REQUEST: InstalledPluginVersion = { 39 | ...PLUGIN_REQUEST_BASE, 40 | obsidianPluginId: UNKNOWN_PLUGIN_ID.toUpperCase(), 41 | }; 42 | 43 | const now = dayjs('2022-09-30T18:44:09-04:00'); 44 | 45 | let id = 1000; 46 | 47 | describe('get-releases', () => { 48 | let getReleases: GetReleases; 49 | let config: GetReleasesConfiguration; 50 | let pluginRepository: PluginRepository; 51 | let releaseRepository: ReleaseRepository; 52 | let releaseApi: ReleaseApi; 53 | 54 | let request: NewPluginVersionRequest; 55 | let result: PluginReleases[]; 56 | 57 | beforeEach(() => { 58 | config = { 59 | releasesFetchedPerPlugin: 2, 60 | maxReleaseNoteLength: 100, 61 | releaseCacheLengthMultiplierSeconds: 10, 62 | pluginCacheLengthDivisor: 1, 63 | maxManifestsToFetchPerPlugin: 2, 64 | ignoreReleasesForThisPlugin: false, 65 | }; 66 | 67 | pluginRepository = { 68 | getPluginsById: jest.fn(), 69 | }; 70 | releaseRepository = { 71 | getReleases: jest.fn(), 72 | save: jest.fn(), 73 | }; 74 | releaseApi = { 75 | fetchReleases: jest.fn(), 76 | fetchReleaseManifest: jest.fn(), 77 | fetchMasterManifest: jest.fn(), 78 | }; 79 | 80 | getReleases = new GetReleases(config, pluginRepository, releaseRepository, releaseApi); 81 | }); 82 | 83 | let plugin1FirstRelease: ApiReleases; 84 | const PLUGIN1_RELEASE_1_MAIN_JS_ID = id++; 85 | const PLUGIN1_RELEASE1_MANIFEST_ID = id++; 86 | let plugin1SecondRelease: ApiReleases; 87 | const PLUGIN1_RELEASE_2_MAIN_JS_ID = id++; 88 | const PLUGIN1_RELEASE2_MANIFEST_ID = id++; 89 | 90 | let plugin2Release1: ApiReleases; 91 | const PLUGIN2_MAIN_JS_ID = id++; 92 | const PLUGIN2_MANIFEST_ID = id++; 93 | 94 | let plugin2CachedReleaseRecord: PluginReleasesRecord; 95 | 96 | beforeEach(() => { 97 | plugin1FirstRelease = { 98 | id: id++, 99 | name: 'Plugin 1 Release 1', 100 | tag_name: PLUGIN_1_INSTALLED_VERSION, 101 | prerelease: false, 102 | draft: false, 103 | published_at: '2022-09-16T08:00:00Z', 104 | body: 'Plugin 1 release notes\n- Note1', 105 | assets: [ 106 | { 107 | id: PLUGIN1_RELEASE_1_MAIN_JS_ID, 108 | name: 'main.js', 109 | download_count: 1000, 110 | updated_at: '2022-09-16T09:00:00Z', 111 | }, 112 | { 113 | id: PLUGIN1_RELEASE1_MANIFEST_ID, 114 | name: 'manifest.json', 115 | download_count: 1200, 116 | updated_at: '2022-09-16T08:00:01Z', 117 | }, 118 | ], 119 | }; 120 | plugin1SecondRelease = { 121 | id: id++, 122 | name: 'Plugin1 Release2', 123 | tag_name: PLUGIN_1_NEW_VERSION_NUM, 124 | prerelease: false, 125 | draft: false, 126 | published_at: '2022-09-17T08:00:00Z', 127 | body: 'Plugin 1 release notes\n- Note2', 128 | assets: [ 129 | { 130 | id: PLUGIN1_RELEASE_2_MAIN_JS_ID, 131 | name: 'main.js', 132 | download_count: 2000, 133 | updated_at: '2022-07-17T10:00:00Z', 134 | }, 135 | { 136 | id: PLUGIN1_RELEASE2_MANIFEST_ID, 137 | name: 'manifest.json', 138 | download_count: 2001, 139 | updated_at: '2022-07-17T10:00:01Z', 140 | }, 141 | { 142 | id: 2324, 143 | name: 'styles.css', 144 | download_count: 2002, 145 | updated_at: '2022-07-17T10:00:02Z', 146 | }, 147 | ], 148 | }; 149 | plugin2Release1 = { 150 | id: id++, 151 | name: 'Plugin 2 release 1', 152 | tag_name: PLUGIN_2_INSTALLED_VERSION, 153 | prerelease: false, 154 | draft: false, 155 | published_at: '2022-08-16T08:00:00Z', 156 | body: 'Plugin 2 release notes\n- Note2', 157 | assets: [ 158 | { 159 | id: PLUGIN2_MAIN_JS_ID, 160 | name: 'main.js', 161 | download_count: 3000, 162 | updated_at: '2022-07-16T09:00:00Z', 163 | }, 164 | { 165 | id: PLUGIN2_MANIFEST_ID, 166 | name: 'manifest.json', 167 | download_count: 1200, 168 | updated_at: '2022-07-16T08:00:01Z', 169 | }, 170 | { 171 | id: 3334, 172 | name: 'styles.css', 173 | download_count: 2002, 174 | updated_at: '2022-07-17T10:00:02Z', 175 | }, 176 | ], 177 | }; 178 | 179 | plugin2CachedReleaseRecord = { 180 | pluginId: PLUGIN_2_ID, 181 | pluginRepo: PLUGIN_2_REPO, 182 | lastFetchedFromGithub: now.format(), 183 | lastFetchedETag: 'some etag', 184 | releases: [ 185 | { 186 | id: plugin2Release1.id, 187 | versionName: plugin2Release1.name, 188 | versionNumber: plugin2Release1.tag_name, 189 | notes: plugin2Release1.body || '', 190 | areNotesTruncated: false, 191 | downloads: 3000, 192 | publishedAt: plugin2Release1.published_at, 193 | sourceCodeUpdatedAt: '2022-07-16T09:00:00Z', 194 | minObsidianVersion: '15.15.0', 195 | manifestLastUpdatedAt: '2022-07-16T08:00:01Z', 196 | }, 197 | ], 198 | }; 199 | }); 200 | 201 | describe('Unexpected request inputs', () => { 202 | it('handles an empty request', async () => { 203 | request = { currentPluginVersions: [] }; 204 | pluginRepository.getPluginsById = jest.fn().mockResolvedValue({}); 205 | 206 | result = await getReleases.execute(request, now); 207 | 208 | expect(result).toEqual([]); 209 | }); 210 | 211 | it('handles unknown plugin ids', async () => { 212 | request = { currentPluginVersions: [UNKNOWN_PLUGIN_REQUEST] }; 213 | pluginRepository.getPluginsById = jest.fn().mockResolvedValue({}); 214 | 215 | result = await getReleases.execute(request, now); 216 | 217 | expect(result).toEqual([]); 218 | expect(pluginRepository.getPluginsById).toHaveBeenCalledWith([UNKNOWN_PLUGIN_ID]); 219 | }); 220 | }); 221 | 222 | describe('cacheing of releases', () => { 223 | it('fetches up-to-date releases the first time seeing a plugin', async () => { 224 | request = { 225 | currentPluginVersions: [UNKNOWN_PLUGIN_REQUEST, PLUGIN_1_REQUEST, PLUGIN_2_REQUEST], 226 | }; 227 | 228 | pluginRepository.getPluginsById = jest.fn().mockResolvedValue({ 229 | [PLUGIN_1_ID]: PLUGIN_1_RECORD, 230 | [PLUGIN_2_ID]: PLUGIN_2_RECORD, 231 | }); 232 | releaseRepository.getReleases = jest.fn().mockResolvedValue([]); 233 | releaseApi.fetchReleases = jest 234 | .fn() 235 | .mockResolvedValueOnce({ 236 | hasChanges: true, 237 | releases: [plugin1FirstRelease, plugin1SecondRelease], 238 | etag: 'some etag', 239 | }) 240 | .mockResolvedValueOnce({ 241 | hasChanges: true, 242 | releases: [plugin2Release1], 243 | etag: 'some etag2', 244 | }); 245 | releaseApi.fetchMasterManifest = jest 246 | .fn() 247 | .mockResolvedValueOnce({ 248 | hasChanges: true, 249 | manifest: { 250 | version: PLUGIN_1_NEW_VERSION_NUM, 251 | minAppVersion: '16.0.0', 252 | }, 253 | etag: 'master-manifest-etag-plugin1', 254 | }) 255 | .mockResolvedValueOnce({ 256 | hasChanges: true, 257 | manifest: { 258 | version: PLUGIN_2_INSTALLED_VERSION, 259 | minAppVersion: '15.15.0', 260 | }, 261 | etag: 'master-manifest-etag-plugin2', 262 | }); 263 | releaseApi.fetchReleaseManifest = jest 264 | .fn() 265 | .mockResolvedValueOnce({ 266 | version: PLUGIN_1_NEW_VERSION_NUM, 267 | minAppVersion: '16.0.0', 268 | }) 269 | .mockResolvedValueOnce({ 270 | version: PLUGIN_1_INSTALLED_VERSION, 271 | minAppVersion: '15.16.0', 272 | }) 273 | .mockResolvedValueOnce({ 274 | version: PLUGIN_2_INSTALLED_VERSION, 275 | minAppVersion: '15.15.0', 276 | }); 277 | 278 | result = await getReleases.execute(request, now); 279 | 280 | expect(pluginRepository.getPluginsById).toHaveBeenCalledWith([ 281 | UNKNOWN_PLUGIN_ID, 282 | PLUGIN_1_ID, 283 | PLUGIN_2_ID, 284 | ]); 285 | 286 | expect(releaseRepository.getReleases).toHaveBeenCalledWith([PLUGIN_1_ID, PLUGIN_2_ID]); 287 | 288 | expect(releaseApi.fetchReleases).toHaveBeenNthCalledWith( 289 | 1, 290 | PLUGIN_1_REPO, 291 | config.releasesFetchedPerPlugin, 292 | undefined 293 | ); 294 | expect(releaseApi.fetchReleases).toHaveBeenNthCalledWith( 295 | 2, 296 | PLUGIN_2_REPO, 297 | config.releasesFetchedPerPlugin, 298 | undefined 299 | ); 300 | 301 | expect(releaseApi.fetchMasterManifest).toHaveBeenNthCalledWith( 302 | 1, 303 | PLUGIN_1_REPO, 304 | undefined 305 | ); 306 | expect(releaseApi.fetchMasterManifest).toHaveBeenNthCalledWith( 307 | 2, 308 | PLUGIN_2_REPO, 309 | undefined 310 | ); 311 | 312 | expect(releaseApi.fetchReleaseManifest).toHaveBeenCalledTimes(3); 313 | expect(releaseApi.fetchReleaseManifest).toHaveBeenNthCalledWith( 314 | 1, 315 | PLUGIN_1_REPO, 316 | PLUGIN1_RELEASE2_MANIFEST_ID 317 | ); 318 | expect(releaseApi.fetchReleaseManifest).toHaveBeenNthCalledWith( 319 | 2, 320 | PLUGIN_1_REPO, 321 | PLUGIN1_RELEASE1_MANIFEST_ID 322 | ); 323 | expect(releaseApi.fetchReleaseManifest).toHaveBeenNthCalledWith( 324 | 3, 325 | PLUGIN_2_REPO, 326 | PLUGIN2_MANIFEST_ID 327 | ); 328 | 329 | expect(releaseRepository.save).toHaveBeenCalledTimes(1); 330 | 331 | //@ts-ignore 332 | const saved = releaseRepository.save.mock.calls[0][0] as PluginReleasesRecord[]; 333 | expect(saved.length).toBe(2); 334 | 335 | expect(saved[0].pluginId).toBe(PLUGIN_1_ID); 336 | expect(saved[0].pluginRepo).toBe(PLUGIN_1_REPO); 337 | expect(saved[0].masterManifest).toEqual({ 338 | versionNumber: PLUGIN_1_NEW_VERSION_NUM, 339 | etag: 'master-manifest-etag-plugin1', 340 | }); 341 | expect(saved[0].lastFetchedETag).toBe('some etag'); 342 | expect(saved[0].lastFetchedFromGithub).toBe('2022-09-30T18:44:09-04:00'); 343 | expect(saved[0].releases.length).toBe(2); 344 | expect(saved[0].releases[0]).toEqual( 345 | expect.objectContaining({ 346 | id: plugin1SecondRelease.id, 347 | versionName: plugin1SecondRelease.name, 348 | versionNumber: plugin1SecondRelease.tag_name, 349 | notes: plugin1SecondRelease.body, 350 | areNotesTruncated: false, 351 | downloads: 2000, 352 | fileAssetIds: { 353 | manifestJson: PLUGIN1_RELEASE2_MANIFEST_ID, 354 | mainJs: PLUGIN1_RELEASE_2_MAIN_JS_ID, 355 | styleCss: 2324, 356 | }, 357 | publishedAt: plugin1SecondRelease.published_at, 358 | sourceCodeUpdatedAt: '2022-07-17T10:00:00Z', 359 | minObsidianVersion: '16.0.0', 360 | manifestLastUpdatedAt: '2022-07-17T10:00:01Z', 361 | }) 362 | ); 363 | expect(saved[0].releases[1]).toEqual( 364 | expect.objectContaining({ 365 | id: plugin1FirstRelease.id, 366 | versionName: plugin1FirstRelease.name, 367 | versionNumber: plugin1FirstRelease.tag_name, 368 | notes: plugin1FirstRelease.body, 369 | areNotesTruncated: false, 370 | downloads: 1000, 371 | fileAssetIds: { 372 | manifestJson: PLUGIN1_RELEASE1_MANIFEST_ID, 373 | mainJs: PLUGIN1_RELEASE_1_MAIN_JS_ID, 374 | styleCss: undefined, 375 | }, 376 | publishedAt: plugin1FirstRelease.published_at, 377 | sourceCodeUpdatedAt: '2022-09-16T09:00:00Z', 378 | minObsidianVersion: '15.16.0', 379 | manifestLastUpdatedAt: '2022-09-16T08:00:01Z', 380 | }) 381 | ); 382 | 383 | expect(saved[1].pluginId).toBe(PLUGIN_2_ID); 384 | expect(saved[1].pluginRepo).toBe(PLUGIN_2_REPO); 385 | expect(saved[1].masterManifest).toEqual({ 386 | versionNumber: PLUGIN_2_INSTALLED_VERSION, 387 | etag: 'master-manifest-etag-plugin2', 388 | }); 389 | expect(saved[1].lastFetchedETag).toBe('some etag2'); 390 | expect(saved[1].lastFetchedFromGithub).toBe('2022-09-30T18:44:09-04:00'); 391 | expect(saved[1].releases.length).toBe(1); 392 | expect(saved[1].releases[0]).toEqual( 393 | expect.objectContaining({ 394 | id: plugin2Release1.id, 395 | versionName: plugin2Release1.name, 396 | versionNumber: plugin2Release1.tag_name, 397 | notes: plugin2Release1.body, 398 | areNotesTruncated: false, 399 | downloads: 3000, 400 | fileAssetIds: { 401 | manifestJson: PLUGIN2_MANIFEST_ID, 402 | mainJs: PLUGIN2_MAIN_JS_ID, 403 | styleCss: 3334, 404 | }, 405 | publishedAt: plugin2Release1.published_at, 406 | sourceCodeUpdatedAt: '2022-07-16T09:00:00Z', 407 | minObsidianVersion: '15.15.0', 408 | manifestLastUpdatedAt: '2022-07-16T08:00:01Z', 409 | }) 410 | ); 411 | }); 412 | 413 | it('fetches up-to-date releases if cached values have expired', async () => { 414 | request = { 415 | currentPluginVersions: [PLUGIN_2_REQUEST], 416 | }; 417 | 418 | pluginRepository.getPluginsById = jest.fn().mockResolvedValue({ 419 | [PLUGIN_2_ID]: PLUGIN_2_RECORD, 420 | }); 421 | 422 | plugin2CachedReleaseRecord.lastFetchedFromGithub = now 423 | .subtract(config.releaseCacheLengthMultiplierSeconds + 1, 'seconds') 424 | .format(); 425 | 426 | releaseRepository.getReleases = jest.fn().mockResolvedValue([]); 427 | releaseApi.fetchReleases = jest.fn().mockResolvedValueOnce({ 428 | hasChanges: true, 429 | releases: [plugin2Release1], 430 | etag: 'some etag2', 431 | }); 432 | 433 | result = await getReleases.execute(request, now); 434 | 435 | expect(releaseApi.fetchReleases).toHaveBeenCalledTimes(1); 436 | expect(releaseApi.fetchMasterManifest).toHaveBeenCalledTimes(1); 437 | }); 438 | 439 | it("uses cached releases that haven't expired", () => {}); 440 | }); 441 | 442 | describe('processing of release data', () => { 443 | it('generates the expected client response', () => {}); 444 | 445 | it('sorts releases by published date', () => {}); 446 | 447 | it("removes releases that are older than the user's installed version", () => {}); 448 | 449 | it('handles a missing main.js file', () => {}); 450 | 451 | it('handles a missing manifest.json file', () => {}); 452 | 453 | it('handles a plugin without releases', () => {}); 454 | 455 | it('truncates large release notes', () => {}); 456 | }); 457 | }); 458 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/src/handler.ts: -------------------------------------------------------------------------------- 1 | import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda'; 2 | import { NewPluginVersionRequest } from '../../../oput-common'; 3 | import { CloudWatchMetricLogger } from './MetricLogger'; 4 | import { DynamoDBReleaseRepository } from './ReleaseRepository/DynamoDBReleaseRepository'; 5 | import { GetReleases, GetReleasesConfiguration } from './get-releases'; 6 | import { GithubReleaseApi } from './ReleaseApi'; 7 | import { S3PluginRepository } from './PluginRepository'; 8 | import { RedisReleaseRepository } from './ReleaseRepository/RedisReleaseRepository'; 9 | import { RedisClient } from './redisClient'; 10 | import { ReleaseRepository } from './ReleaseRepository'; 11 | import { FallbackReleaseRepository } from './ReleaseRepository/FallbackReleaseRepository'; 12 | import { createHash } from 'crypto'; 13 | 14 | let _getReleases: GetReleases | null = null; 15 | let _redisClient: RedisClient; 16 | let _metricsLogger: CloudWatchMetricLogger; 17 | 18 | const IP_HEADER = 'x-forwarded-for'; 19 | 20 | export async function main(event: APIGatewayProxyEventV2): Promise { 21 | if (event.requestContext.http.method.toLowerCase() !== 'post') { 22 | return badRequest('Post request expected'); 23 | } 24 | if (!event.body) { 25 | return badRequest('Request body is missing'); 26 | } 27 | 28 | let request: NewPluginVersionRequest; 29 | try { 30 | let body = event.body; 31 | if (event.isBase64Encoded) { 32 | body = Buffer.from(body, 'base64').toString('utf-8'); 33 | } 34 | request = JSON.parse(body); 35 | } catch (e) { 36 | console.error(`Bad request body: ${event.body}`, e); 37 | return badRequest('Request body is invalid'); 38 | } 39 | 40 | console.log( 41 | `Request for ${request?.currentPluginVersions.length} plugins from ${getIdentifier(event)}` 42 | ); 43 | request.currentPluginVersions = (request.currentPluginVersions || []).slice( 44 | 0, 45 | getIntEnv('OPUC_MAX_PLUGIN_COUNT_PROCESSED') 46 | ); 47 | 48 | try { 49 | const getReleases: GetReleases = buildGetReleases(); 50 | const response = await getReleases.execute(request); 51 | 52 | return { 53 | body: JSON.stringify(response), 54 | statusCode: 200, 55 | headers: { 56 | 'Access-Control-Allow-Origin': '*', 57 | }, 58 | }; 59 | } catch (err) { 60 | console.error('Failed handling request', request, err); 61 | throw err; 62 | } finally { 63 | if (_redisClient) { 64 | await _redisClient.close(); 65 | } 66 | if (_metricsLogger) { 67 | _metricsLogger.flush(); 68 | } 69 | } 70 | } 71 | 72 | function badRequest(message: string) { 73 | console.warn('Bad request: ', message); 74 | return { 75 | body: message, 76 | statusCode: 400, 77 | headers: { 78 | 'Access-Control-Allow-Origin': '*', 79 | }, 80 | }; 81 | } 82 | 83 | function buildGetReleases(): GetReleases { 84 | if (_getReleases != null) { 85 | return _getReleases; 86 | } 87 | 88 | _metricsLogger = new CloudWatchMetricLogger(getEnv('OPUC_METRIC_NAMESPACE')); 89 | 90 | const pluginRepository = new S3PluginRepository( 91 | getEnv('OPUC_PLUGINS_LIST_BUCKET_NAME'), 92 | _metricsLogger 93 | ); 94 | 95 | const releaseApi = new GithubReleaseApi( 96 | getEnv('OPUC_GITHUB_ACCESS_TOKEN'), 97 | _metricsLogger, 98 | getIntEnv('OPUC_GITHUB_API_TIMEOUT_MS') 99 | ); 100 | 101 | const releaseRepositoryUsageOrder: ReleaseRepository[] = []; 102 | if (getBooleanEnv('OPUC_USE_REDIS_RELEASE_REPOSITORY')) { 103 | _redisClient = new RedisClient( 104 | getEnv('OPUC_REDIS_URL'), 105 | getEnv('OPUC_REDIS_PASSWORD'), 106 | getBooleanEnv('OPUC_IS_PROD'), 107 | _metricsLogger 108 | ); 109 | releaseRepositoryUsageOrder.push(new RedisReleaseRepository(_redisClient, _metricsLogger)); 110 | } 111 | if (getBooleanEnv('OPUC_USE_DYNAMODB_RELEASE_REPOSITORY')) { 112 | releaseRepositoryUsageOrder.push( 113 | new DynamoDBReleaseRepository(getEnv('OPUC_PLUGIN_RELEASES_TABLE_NAME')) 114 | ); 115 | } 116 | //fallback to dynamodb because redis only has 30mb and up to 30 connections in free tier 117 | const fallbackReleaseRepository = new FallbackReleaseRepository(releaseRepositoryUsageOrder); 118 | 119 | const config: GetReleasesConfiguration = { 120 | releaseCacheLengthMultiplierSeconds: getIntEnv( 121 | 'OPUC_RELEASES_CACHE_LENGTH_SECONDS_MULTIPLIER' 122 | ), 123 | pluginCacheLengthDivisor: getIntEnv('OPUC_PLUGIN_CACHE_LENGTH_DIVISOR'), 124 | releasesFetchedPerPlugin: getIntEnv('OPUC_RELEASES_FETCHED_PER_PLUGIN'), 125 | maxReleaseNoteLength: getIntEnv('OPUC_MAX_RELEASE_NOTE_LENGTH'), 126 | maxManifestsToFetchPerPlugin: getIntEnv('OPUC_MAX_MANIFESTS_TO_FETCH_PER_PLUGIN'), 127 | ignoreReleasesForThisPlugin: getBooleanEnv('OPUC_IGNORE_RELEASES_FOR_THIS_PLUGIN'), 128 | }; 129 | 130 | _getReleases = new GetReleases(config, pluginRepository, fallbackReleaseRepository, releaseApi); 131 | return _getReleases; 132 | } 133 | 134 | function getEnv(key: string): string { 135 | const value = process.env[key]; 136 | if (key == undefined || key === '') { 137 | throw new Error(`Missing environment key ${key}, value is ${value}`); 138 | } 139 | return value as string; 140 | } 141 | 142 | function getIntEnv(key: string): number { 143 | const value = parseInt(getEnv(key)); 144 | if (isNaN(value)) { 145 | throw new Error(`Non-numeric key ${key}: ${value}`); 146 | } 147 | return value; 148 | } 149 | 150 | function getBooleanEnv(key: string): boolean { 151 | const value = getEnv(key); 152 | if (value !== 'true' && value !== 'false') { 153 | throw new Error(`Non-boolean key: ${key}: ${value}`); 154 | } 155 | return value === 'true'; 156 | } 157 | 158 | function getIdentifier(event: APIGatewayProxyEventV2): string { 159 | if (IP_HEADER in event.headers && !!event.headers[IP_HEADER]) { 160 | const ip = event.headers[IP_HEADER]; 161 | const md5 = createHash('md5'); 162 | const salt = getEnv('OPUC_SALT'); 163 | md5.update(ip + salt); 164 | return md5.digest('hex'); 165 | } 166 | return '?'; 167 | } 168 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/src/redisClient.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from 'redis'; 2 | import { RedisClientType } from '@redis/client'; 3 | import { MetricLogger } from './MetricLogger'; 4 | 5 | export class RedisClient { 6 | private url: string; 7 | private password: string; 8 | private isProd: boolean; 9 | private metricLogger: MetricLogger; 10 | 11 | private redisClient: RedisClientType | null = null; 12 | 13 | constructor(url: string, password: string, isProd: boolean, metricLogger: MetricLogger) { 14 | this.url = url; 15 | this.password = password; 16 | this.isProd = isProd; 17 | this.metricLogger = metricLogger; 18 | } 19 | 20 | public async getJson(namespace: string, keys: string[]): Promise { 21 | if (keys.length === 0) { 22 | return []; 23 | } 24 | 25 | const client = await this.getClient(); 26 | 27 | const namespacedKeys = keys.map((key) => this.makeNamespacedKey(namespace, key)); 28 | const results = await client.mGet(namespacedKeys); 29 | const cacheHits = results 30 | .filter((resultStr) => resultStr != null) 31 | .map((resultStr) => JSON.parse(resultStr as string)); 32 | 33 | return cacheHits; 34 | } 35 | 36 | public async putJson(namespace: string, items: T[], keyExtractor: (item: T) => string) { 37 | if (items.length === 0) { 38 | return; 39 | } 40 | 41 | const client = await this.getClient(); 42 | 43 | const keyValues = items.reduce((combined: Record, item) => { 44 | const key = this.makeNamespacedKey(namespace, keyExtractor(item)); 45 | combined[key] = JSON.stringify(item); 46 | return combined; 47 | }, {}); 48 | 49 | await client.mSet(keyValues); 50 | } 51 | 52 | public async close(): Promise { 53 | try { 54 | const redisClient = this.redisClient; 55 | if (redisClient != null) { 56 | this.redisClient = null; 57 | await redisClient.disconnect(); 58 | } 59 | } catch (err) { 60 | console.error('Error cleaning up redis connection', err); 61 | } 62 | } 63 | 64 | private async getClient(): Promise { 65 | if (this.redisClient == null) { 66 | try { 67 | this.redisClient = createClient({ 68 | url: this.url, 69 | password: this.password, 70 | }); 71 | await this.redisClient.connect(); 72 | } catch (err) { 73 | console.error('Error connecting to redis', err); 74 | this.metricLogger.trackErrorCodeOccurrence('REDIS_CONNECTION_ERROR'); 75 | throw err; 76 | } 77 | } 78 | return this.redisClient; 79 | } 80 | 81 | private makeNamespacedKey(namespace: string, key: string) { 82 | return `${this.isProd ? 'prod' : 'dev'}:${namespace}:${key}`; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /backend/get-releases-lambda/src/util.ts: -------------------------------------------------------------------------------- 1 | const DEBUG_LOGS_ENABLED = process.env['OPUC_DEBUG_LOGS_ENABLED'] === 'true'; 2 | 3 | export function isEmpty(collection: any[] | null | undefined) { 4 | return collection == null || collection.length === 0; 5 | } 6 | 7 | export function isString(value: any) { 8 | return typeof value === 'string'; 9 | } 10 | 11 | export function groupById(items: T[], idKey: keyof T): Record { 12 | if (isEmpty(items)) { 13 | return {}; 14 | } 15 | return items.reduce((prev, current) => { 16 | const id = new String(current[idKey]).toString(); 17 | prev[id] = current; 18 | return prev; 19 | }, {} as Record); 20 | } 21 | 22 | export function partition(items: T[], chunkSize: number): T[][] { 23 | const partitions = []; 24 | for (let i = 0; i < items.length; i += chunkSize) { 25 | const chunk = items.slice(i, i + chunkSize); 26 | partitions.push(chunk); 27 | } 28 | return partitions; 29 | } 30 | 31 | export function debug(...args: any[]) { 32 | if (DEBUG_LOGS_ENABLED) { 33 | console.debug(...args); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-plugin-update-checker-backend", 3 | "version": "0.1.0", 4 | "bin": { 5 | "backend": "bin/backend.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "cdk": "cdk", 11 | "test": "jest", 12 | "deploydev": "OPUC_ENV=dev cdk deploy obsidian-plugin-update-checker-dev", 13 | "deploystage": "OPUC_ENV=stage cdk deploy obsidian-plugin-update-checker-dev", 14 | "deployprod": "OPUC_ENV=prod cdk deploy obsidian-plugin-update-checker-prod" 15 | }, 16 | "devDependencies": { 17 | "@types/jest": "^27.5.2", 18 | "@types/node": "16.11.7", 19 | "@types/prettier": "2.6.0", 20 | "aws-cdk": "2.126.0", 21 | "dotenv": "^16.0.2", 22 | "jest": "^27.5.1", 23 | "ts-jest": "^27.1.4", 24 | "ts-node": "^10.9.1", 25 | "typescript": "~3.9.7" 26 | }, 27 | "dependencies": { 28 | "aws-cdk-lib": "2.126.0", 29 | "constructs": "^10.0.0", 30 | "source-map-support": "^0.5.21" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2018" 7 | ], 8 | "declaration": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "noUnusedLocals": false, 15 | "noUnusedParameters": false, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": false, 18 | "inlineSourceMap": true, 19 | "inlineSources": true, 20 | "experimentalDecorators": true, 21 | "strictPropertyInitialization": false, 22 | "typeRoots": [ 23 | "./node_modules/@types" 24 | ] 25 | }, 26 | "exclude": [ 27 | "node_modules", 28 | "cdk.out" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from 'builtin-modules' 4 | import * as dotenv from 'dotenv' 5 | import { existsSync } from 'fs' 6 | 7 | const banner = 8 | `/* 9 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 10 | if you want to view the source, please visit the github repository of this plugin 11 | */ 12 | `; 13 | 14 | const prod = (process.argv[2] === 'production'); 15 | 16 | console.log('using .env') 17 | dotenv.config({path: '.env'}) 18 | 19 | const mainEnvFile = prod ? '.env.prod' : '.env.dev' 20 | console.log(`using ${mainEnvFile}`) 21 | dotenv.config({ 22 | path: mainEnvFile 23 | }) 24 | 25 | if (prod && existsSync('.env.local.prod')) { 26 | console.log('using .env.local.prod') 27 | dotenv.config({path: '.env.local.prod', override: true}) 28 | } 29 | else if (!prod && existsSync('.env.local.dev')) { 30 | console.log('using .env.local.dev') 31 | dotenv.config({path: '.env.local.dev', override: true}) 32 | } 33 | 34 | for (const key in process.env) { 35 | if (key.startsWith('OBSIDIAN_APP')) { 36 | console.log(key, '=', process.env[key]) 37 | } 38 | } 39 | 40 | const define = [ 41 | 'OBSIDIAN_APP_UPDATE_CHECKER_URL', 42 | 'OBSIDIAN_APP_RELEASE_MIN_POLLING_HOURS', 43 | 'OBSIDIAN_APP_POLLING_FREQUENCY_MULTIPLIER', 44 | 'OBSIDIAN_APP_INSTALLED_VERSION_POLLING_SECONDS', 45 | 'OBSIDIAN_APP_ENABLE_REDUX_LOGGER', 46 | 'OBSIDIAN_APP_SIMULATE_UPDATE_PLUGINS', 47 | 'OBSIDIAN_APP_HIDE_THIS_PLUGINS_UPDATES', 48 | 'OBSIDIAN_APP_THIS_PLUGIN_ID', 49 | 'OBSIDIAN_APP_SHOW_STATUS_BAR_ICON_ALL_PLATFORMS', 50 | 'OBSIDIAN_APP_SHOW_RIBBON_ICON_ALL_PLATFORMS', 51 | 'OBSIDIAN_APP_ACTION_BAR_LOCATION_MIDDLE', 52 | ].reduce((prev, current) => { 53 | prev[`process.env.${current}`] = JSON.stringify(process.env[current]) 54 | return prev 55 | }, {}) 56 | 57 | esbuild.build({ 58 | banner: { 59 | js: banner, 60 | }, 61 | entryPoints: ['src/main.tsx'], 62 | bundle: true, 63 | external: [ 64 | 'obsidian', 65 | 'electron', 66 | '@codemirror/autocomplete', 67 | '@codemirror/collab', 68 | '@codemirror/commands', 69 | '@codemirror/language', 70 | '@codemirror/lint', 71 | '@codemirror/search', 72 | '@codemirror/state', 73 | '@codemirror/view', 74 | '@lezer/common', 75 | '@lezer/highlight', 76 | '@lezer/lr', 77 | ...builtins], 78 | format: 'cjs', 79 | watch: !prod, 80 | target: 'es2018', 81 | logLevel: "info", 82 | sourcemap: prod ? false : 'inline', 83 | treeShaking: true, 84 | outfile: 'main.js', 85 | minify: prod, 86 | define, 87 | }).catch(() => process.exit(1)); 88 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['src/', 'oput-common/', 'backend/get-releases-lambda/'], 4 | testMatch: ['**/*.test.ts', '**/*.test.js'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /manifest-beta.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-plugin-update-tracker", 3 | "name": "Plugin Update Tracker", 4 | "version": "1.7.0", 5 | "minAppVersion": "0.15.0", 6 | "description": "Know when installed plugins have updates and evaluate the risk of upgrading", 7 | "author": "Obsidian", 8 | "authorUrl": "https://github.com/swar8080/obsidian-plugin-update-tracker", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-plugin-update-tracker", 3 | "name": "Plugin Update Tracker", 4 | "version": "1.7.0", 5 | "minAppVersion": "0.15.0", 6 | "description": "Know when installed plugins have updates and evaluate the risk of upgrading", 7 | "author": "Obsidian", 8 | "authorUrl": "https://github.com/swar8080/obsidian-plugin-update-tracker", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /oput-common/index.d.ts: -------------------------------------------------------------------------------- 1 | export type NewPluginVersionRequest = { 2 | currentPluginVersions: InstalledPluginVersion[]; 3 | }; 4 | 5 | export type InstalledPluginVersion = { 6 | obsidianPluginId: string; 7 | version: string; 8 | }; 9 | 10 | export type PluginReleases = { 11 | obsidianPluginId: string; 12 | pluginName: string; 13 | pluginRepositoryUrl: string; 14 | pluginRepoPath: string; 15 | newVersions: ReleaseVersion[]; 16 | }; 17 | 18 | type ReleaseVersion = { 19 | releaseId: number; 20 | versionName: string; 21 | versionNumber: string; 22 | minObsidianAppVersion?: string; 23 | notes: string; 24 | areNotesTruncated: boolean; 25 | downloads: number; 26 | isBetaVersion: boolean; 27 | publishedAt: string; 28 | fileAssetIds?: PluginFileAssetIds; 29 | updatedAt: string; 30 | }; 31 | 32 | export type PluginFileAssetIds = { 33 | mainJs: number; 34 | manifestJson: number; 35 | styleCss?: number; 36 | }; 37 | -------------------------------------------------------------------------------- /oput-common/semverCompare.ts: -------------------------------------------------------------------------------- 1 | const FUZZY_SEMVER_PATTERN = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?.*/; 2 | 3 | export function semverCompare(version1: string | null, version2: string | null): number { 4 | if (version1 == version2) { 5 | return 0; 6 | } else if (version1 == null) { 7 | return -1; 8 | } else if (version2 == null) { 9 | return 1; 10 | } 11 | 12 | const v1Match = version1.match(FUZZY_SEMVER_PATTERN); 13 | const v2Match = version2.match(FUZZY_SEMVER_PATTERN); 14 | if (!v1Match && !v2Match) { 15 | return 0; 16 | } else if (!v1Match) { 17 | return -1; 18 | } else if (!v2Match) { 19 | return 1; 20 | } 21 | 22 | let versionPartIndex = 1; 23 | while (versionPartIndex <= 3) { 24 | const v1Part = parseInt(v1Match[versionPartIndex] || '0'); 25 | const v2Part = parseInt(v2Match[versionPartIndex] || '0'); 26 | 27 | if (v1Part !== v2Part) { 28 | return v1Part - v2Part; 29 | } 30 | 31 | versionPartIndex++; 32 | } 33 | 34 | return 0; 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-plugin-update-tracker", 3 | "version": "1.7.0", 4 | "description": "Know when installed plugins have updates and evaluate the risk of upgrading", 5 | "main": "main.js", 6 | "scripts": { 7 | "setup": "sh scripts/repo-setup.sh", 8 | "dev": "node esbuild.config.mjs", 9 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 10 | "version": "node version-bump.mjs && git add manifest.json versions.json", 11 | "storybook": "sh scripts/run-storybook.sh", 12 | "build-storybook": "build-storybook", 13 | "downgrade-self": "sh scripts/change-plugin-version.sh obsidian-plugin-update-tracker 0.0.0" 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@babel/core": "^7.18.13", 20 | "@codemirror/state": "^6.0.0", 21 | "@codemirror/view": "^6.0.0", 22 | "@storybook/addon-actions": "^6.5.10", 23 | "@storybook/addon-essentials": "^6.5.10", 24 | "@storybook/addon-interactions": "^6.5.10", 25 | "@storybook/addon-links": "^6.5.10", 26 | "@storybook/builder-webpack4": "^6.5.10", 27 | "@storybook/manager-webpack4": "^6.5.10", 28 | "@storybook/react": "^6.5.10", 29 | "@storybook/testing-library": "^0.0.13", 30 | "@types/jest": "^29.0.3", 31 | "@types/node": "^16.11.6", 32 | "@types/react": "^18.0.18", 33 | "@types/react-dom": "^18.0.6", 34 | "@types/react-redux": "^7.1.24", 35 | "@types/redux-logger": "^3.0.9", 36 | "@types/styled-components": "^5.1.26", 37 | "babel-loader": "^8.2.5", 38 | "builtin-modules": "3.3.0", 39 | "dotenv": "^16.0.2", 40 | "esbuild": "0.14.47", 41 | "jest": "^29.0.3", 42 | "organize-imports-cli": "^0.10.0", 43 | "prettier": "^2.7.1", 44 | "ts-jest": "^29.0.3", 45 | "tslib": "2.4.0", 46 | "typescript": "4.7.4" 47 | }, 48 | "dependencies": { 49 | "@fortawesome/fontawesome-svg-core": "^6.2.0", 50 | "@fortawesome/free-regular-svg-icons": "^6.2.0", 51 | "@fortawesome/free-solid-svg-icons": "^6.2.0", 52 | "@fortawesome/react-fontawesome": "^0.2.0", 53 | "@reduxjs/toolkit": "^1.8.5", 54 | "dayjs": "^1.11.5", 55 | "lodash": "^4.17.21", 56 | "obsidian": "^0.16.0", 57 | "react": "^18.2.0", 58 | "react-dom": "^18.2.0", 59 | "react-markdown": "^8.0.3", 60 | "react-redux": "^8.0.2", 61 | "redux-logger": "^3.0.6", 62 | "rehype-raw": "^6.1.1", 63 | "rehype-sanitize": "^5.0.1", 64 | "styled-components": "^5.3.5", 65 | "unist-util-visit": "^4.1.2", 66 | "use-change-aware-effect": "^1.0.1" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /scripts/change-plugin-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | pluginId=$1; 4 | pluginVersion=$2; 5 | 6 | startDir=`pwd`; 7 | 8 | cd ../$pluginId; 9 | if [[ "$OSTYPE" == "darwin"* ]]; then 10 | sed -i ".bak" "s/\"version\".*,/\"version\": \"$pluginVersion\",/" manifest.json 11 | else 12 | sed -i.bak "s/\"version\".*,/\"version\": \"$pluginVersion\",/" manifest.json 13 | fi 14 | cat manifest.json; 15 | 16 | cd $startDir; -------------------------------------------------------------------------------- /scripts/exceed-github-rate-limit.ts: -------------------------------------------------------------------------------- 1 | async function main() { 2 | for (let i = 0; i < 60; i++) { 3 | const assetId = 39938488 + i; 4 | await new Promise((resolve) => setTimeout(resolve, 50)); 5 | await fetch( 6 | `https://api.github.com/repos/erichalldev/obsidian-smart-random-note/releases/assets/${assetId}`, 7 | { 8 | method: 'GET', 9 | headers: new Headers({ 10 | Accept: 'application/octet-stream', 11 | 'sec-fetch-mode': 'cors', 12 | 'sec-fetch-site': 'cross-site', 13 | }), 14 | } 15 | ).then(console.log); 16 | } 17 | } 18 | 19 | main(); 20 | -------------------------------------------------------------------------------- /scripts/node_modules-obsidian/index: -------------------------------------------------------------------------------- 1 | //Hack to fix storybook webpack builds and jest unit tests since the obsidian package doesn't include source code 2 | const obsidian = { 3 | requireApiVersion(version) { 4 | if (version === '15.0.0') { 5 | return true 6 | } 7 | else if (version === '16.0.0') { 8 | return false 9 | } 10 | throw new Error("Unhandled version in requireApiVersion mock: " + version) 11 | } 12 | } 13 | module.exports = obsidian -------------------------------------------------------------------------------- /scripts/repo-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | 4 | git config core.hooksPath .githooks; 5 | 6 | cp node_modules/obsidian/index.js /tmp/obsidian-index-tmp.js 7 | npm ci; 8 | cp /tmp/obsidian-index-tmp.js node_modules/obsidian/index.js; 9 | git add --force node_modules/obsidian/index.js; -------------------------------------------------------------------------------- /scripts/reset.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | sh scripts/change-plugin-version.sh dataview 0.5.44; 3 | cp scripts/dataview-v0.5.44-main.sample ../dataview/main.js 4 | sh scripts/change-plugin-version.sh obsidian-excalidraw-plugin 1.7.22; 5 | sh scripts/change-plugin-version.sh smart-random-note 0.1.3; 6 | sh scripts/change-plugin-version.sh obsidian42-brat 0.6.1; 7 | sh scripts/change-plugin-version.sh periodic-notes 0.0.17; 8 | sh scripts/change-plugin-version.sh obsidian-dynamic-highlights 0.3.1; 9 | sh scripts/change-plugin-version.sh obsidian-tasks-plugin 7.5.0; 10 | echo "{}" > data.json; -------------------------------------------------------------------------------- /scripts/run-storybook.sh: -------------------------------------------------------------------------------- 1 | mkdir -p node_modules/obsidian 2 | cp scripts/node_modules-obsidian/index node_modules/obsidian/index.js 3 | NODE_OPTIONS=--openssl-legacy-provider npx start-storybook -p 6006; -------------------------------------------------------------------------------- /src/components/DismissedPluginVersions.tsx: -------------------------------------------------------------------------------- 1 | import { faRotateLeft } from '@fortawesome/free-solid-svg-icons/faRotateLeft'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import { PluginManifest } from 'obsidian'; 4 | import * as React from 'react'; 5 | import styled from 'styled-components'; 6 | import { semverCompare } from '../../oput-common/semverCompare'; 7 | import { PluginDismissedVersions, PluginSettings } from '../domain/pluginSettings'; 8 | import { groupById } from '../domain/util/groupById'; 9 | import { useAppDispatch, useAppSelector } from '../state'; 10 | import { unDismissPluginVersion } from '../state/actionProducers/undismissPluginVersion'; 11 | 12 | interface DismissedPluginVersionsConnectedProps { 13 | persistPluginSettings: (settings: PluginSettings) => Promise; 14 | } 15 | 16 | const DismissedPluginVersionsConnected: React.FC = ({ 17 | persistPluginSettings, 18 | }) => { 19 | const dismissedVersionsByPluginId = useAppSelector( 20 | (state) => state.obsidian.settings.dismissedVersionsByPluginId 21 | ); 22 | const isUpdatingDismissedVersions = useAppSelector( 23 | (state) => state.releases.isUpdatingDismissedVersions 24 | ); 25 | const pluginManifests = useAppSelector((state) => state.obsidian.pluginManifests); 26 | const dispatch = useAppDispatch(); 27 | 28 | async function handleUndismissVersion(pluginId: string, versionNumber: string) { 29 | if (!isUpdatingDismissedVersions) { 30 | return dispatch( 31 | unDismissPluginVersion({ 32 | pluginId, 33 | versionNumber, 34 | persistPluginSettings, 35 | }) 36 | ); 37 | } 38 | } 39 | 40 | return ( 41 | 46 | ); 47 | }; 48 | 49 | interface DismissedPluginVersionsProps { 50 | dismissedVersionsByPluginId: Record; 51 | pluginManifests: PluginManifest[]; 52 | onClickUndismissVersion: (pluginId: string, versionNumber: string) => Promise; 53 | } 54 | 55 | const DismissedPluginVersions: React.FC = ({ 56 | dismissedVersionsByPluginId, 57 | pluginManifests, 58 | onClickUndismissVersion, 59 | }) => { 60 | const rows = React.useMemo(() => { 61 | let denormalizedRows: DismissedVersionRow[] = []; 62 | 63 | const manifestById = groupById(pluginManifests, 'id'); 64 | 65 | const pluginIds = Object.keys(dismissedVersionsByPluginId); 66 | pluginIds 67 | .filter((pluginId) => pluginId in manifestById) 68 | .forEach((pluginId) => { 69 | const pluginDismissedVersions = dismissedVersionsByPluginId[pluginId]; 70 | 71 | pluginDismissedVersions.dismissedVersions.forEach((version) => 72 | denormalizedRows.push({ 73 | pluginId, 74 | pluginRepo: pluginDismissedVersions.pluginRepoPath, 75 | pluginName: manifestById[pluginId].name, 76 | ...version, 77 | }) 78 | ); 79 | }); 80 | 81 | denormalizedRows = denormalizedRows 82 | .filter((version) => { 83 | //only keep newer versions than what's installed 84 | const installedVersion = manifestById[version.pluginId].version; 85 | return semverCompare(version.versionNumber, installedVersion) > 0; 86 | }) 87 | .sort((v1, v2) => { 88 | if (v1.pluginId !== v2.pluginId) { 89 | //list plugins alphabetically 90 | return v1.pluginName.localeCompare(v2.pluginName); 91 | } 92 | 93 | //list newer versions first for the same plugin 94 | return -v1.publishedAt.localeCompare(v2.publishedAt); 95 | }); 96 | 97 | return denormalizedRows; 98 | }, [dismissedVersionsByPluginId, pluginManifests]); 99 | 100 | const instructions = `You can hide specific plugin versions from appearing in the plugin icon count and plugin update list, and then unhide them below${ 101 | rows.length > 0 ? ':' : '' 102 | }`; 103 | return ( 104 |
105 |
106 | {instructions} 107 | 108 | {rows.length > 0 && 109 | rows.map((row) => { 110 | const releaseUrl = `https://github.com/${row.pluginRepo}/releases/tag/${row.versionNumber}`; 111 | 112 | return ( 113 |
114 | 116 | onClickUndismissVersion(row.pluginId, row.versionNumber) 117 | } 118 | aria-label="Restore" 119 | data-tooltip-position="top" 120 | className="clickable-icon" 121 | > 122 | 123 | 124 | {row.pluginName} ( 125 | {row.versionName} 126 | ) 127 |
128 | ); 129 | })} 130 | 131 | {rows.length === 0 && ( 132 | No versions are ignored 133 | )} 134 |
135 |
136 | ); 137 | }; 138 | 139 | type DismissedVersionRow = { 140 | pluginId: string; 141 | pluginName: string; 142 | pluginRepo: string; 143 | versionName: string; 144 | versionNumber: string; 145 | publishedAt: string; 146 | }; 147 | 148 | const DivDismissedVersionRows = styled.div` 149 | margin-top: 0.5rem; 150 | `; 151 | 152 | const PDismissedVersionInfo = styled.p` 153 | margin: 0; 154 | `; 155 | 156 | const PNoVersionsDismissed = styled.p` 157 | font-style: italic; 158 | font-size: var(--font-ui-small); 159 | margin: 0; 160 | `; 161 | 162 | const SpanUndismissIcon = styled.span` 163 | color: var(--icon-color); 164 | opacity: var(--icon-opacity); 165 | cursor: var(--cursor); 166 | display: inline; 167 | padding-left: 0.375rem; 168 | `; 169 | 170 | export default DismissedPluginVersionsConnected; 171 | -------------------------------------------------------------------------------- /src/components/PluginUpdateList.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | import dayjs from 'dayjs'; 3 | import React from 'react'; 4 | import { PluginUpdateList, PluginViewModel } from './PluginUpdateList'; 5 | 6 | type Story = ComponentStory; 7 | 8 | const PLUGIN_UPDATE_LIST_BASE = { 9 | selectedPluginIds: [], 10 | selectedPluginCount: 0, 11 | handleToggleSelection: () => {}, 12 | handleToggleSelectAll: () => {}, 13 | handleInstall: () => Promise.resolve(), 14 | isUpdatingDismissedVersions: false, 15 | handleClickDismissPluginVersions: () => {}, 16 | }; 17 | 18 | const MOST_RECENTLY_UPDATED_PLUGIN_TIME = dayjs().subtract(32, 'hours'); 19 | 20 | let pluginId = 10; 21 | 22 | const PLUGIN_VIEW_MODEL_BASE: PluginViewModel = { 23 | id: 'plugin1', 24 | name: 'Dataview', 25 | downloads: 12355, 26 | lastUpdatedTime: MOST_RECENTLY_UPDATED_PLUGIN_TIME, 27 | githubRepositoryUrl: 'https://github.com/blacksmithgu/obsidian-dataview', 28 | installedVersionNumber: '0.5.44', 29 | latestInstallableVersionNumber: '0.5.46', 30 | latestInstallableVersionIsBeta: false, 31 | releaseNotes: [ 32 | { 33 | releaseId: 101, 34 | versionName: 'Release 0.5.46 (beta)', 35 | versionNumber: '0.5.46', 36 | notes: 'Some release notes', 37 | isBetaVersion: true, 38 | }, 39 | { 40 | releaseId: 100, 41 | versionName: 'Relase 0.5.45', 42 | versionNumber: '0.5.45', 43 | notes: 'Some release notes', 44 | isBetaVersion: true, 45 | }, 46 | ], 47 | hasInstallableReleaseAssets: true, 48 | isPluginEnabled: true, 49 | }; 50 | 51 | export const MixOfPlugins: Story = () => { 52 | const noReleaseNotes = { 53 | ...PLUGIN_VIEW_MODEL_BASE, 54 | id: 'no release notes', 55 | name: 'No Release Notes', 56 | lastUpdatedTime: dayjs().subtract(48, 'hours'), 57 | releaseNotes: [], 58 | downloads: 32, 59 | }; 60 | const plugin3 = { 61 | ...PLUGIN_VIEW_MODEL_BASE, 62 | id: 'plugin3', 63 | lastUpdatedTime: dayjs().subtract(72, 'hours'), 64 | name: 'Plugin 3', 65 | }; 66 | const plugin4 = { 67 | ...PLUGIN_VIEW_MODEL_BASE, 68 | id: 'plugin4', 69 | lastUpdatedTime: dayjs().subtract(48, 'hours'), 70 | name: 'Plugin 4', 71 | }; 72 | const pluginBeta = { 73 | ...PLUGIN_VIEW_MODEL_BASE, 74 | id: `Plugin ${pluginId++}`, 75 | name: 'Plugin beta', 76 | latestInstallableVersionIsBeta: true, 77 | }; 78 | const pluginDisabled = { 79 | ...PLUGIN_VIEW_MODEL_BASE, 80 | id: `Plugin ${pluginId++}`, 81 | name: 'Plugin disabled', 82 | isPluginEnabled: false, 83 | }; 84 | const pluginBetaAndDisabled = { 85 | ...PLUGIN_VIEW_MODEL_BASE, 86 | id: `Plugin ${pluginId++}`, 87 | name: 'Plugin beta and disabled', 88 | latestInstallableVersionIsBeta: true, 89 | isPluginEnabled: false, 90 | }; 91 | return ( 92 | 105 | ); 106 | }; 107 | 108 | function pluginWithNotes(name: string, notes: string): PluginViewModel { 109 | return { 110 | ...PLUGIN_VIEW_MODEL_BASE, 111 | id: `Plugin ${pluginId++}`, 112 | name, 113 | releaseNotes: [ 114 | { 115 | releaseId: pluginId++, 116 | versionName: '1.2.3 Beta', 117 | versionNumber: '1.2.3', 118 | notes, 119 | isBetaVersion: true, 120 | }, 121 | ], 122 | }; 123 | } 124 | 125 | export const MarkdownParsingAndEnrichment: Story = () => { 126 | return ( 127 |

h2 header

image' 139 | ), 140 | pluginWithNotes('Contains emoji', 'fix: 🐛'), 141 | pluginWithNotes( 142 | 'Contains raw URLs', 143 | ` 144 | * feat: Add date picker in Reading mode and Tasks Query results by @claremacrae in https://github.com/obsidian-tasks-group/obsidian-tasks/pull/3038 145 | * feat: Add a date picker to the Edit Task modal by @claremacrae in https://github.com/obsidian-tasks-group/obsidian-tasks/pull/3052 146 | ` 147 | ), 148 | ]} 149 | {...PLUGIN_UPDATE_LIST_BASE} 150 | actionBarLocation="bottom" 151 | /> 152 | ); 153 | }; 154 | 155 | export const LotsOfReleaseNotesPerformance: Story = () => { 156 | const plugins = []; 157 | for (let i = 0; i < 30; i++) { 158 | plugins.push( 159 | pluginWithNotes( 160 | `Contains raw URLs ${i}`, 161 | ` 162 | * feat: Add date picker in Reading mode and Tasks Query results by @claremacrae in https://github.com/obsidian-tasks-group/obsidian-tasks/pull/3038 163 | * feat: Add a date picker to the Edit Task modal by @claremacrae in https://github.com/obsidian-tasks-group/obsidian-tasks/pull/3052 164 | *

h2 header

image 178 | ); 179 | }; 180 | 181 | export default { 182 | title: 'PluginUpdateList', 183 | component: PluginUpdateList, 184 | } as ComponentMeta; 185 | -------------------------------------------------------------------------------- /src/components/PluginUpdateManager.tsx: -------------------------------------------------------------------------------- 1 | import { Platform } from 'obsidian'; 2 | import * as React from 'react'; 3 | import { PluginSettings } from '../domain/pluginSettings'; 4 | import { useAppSelector } from '../state'; 5 | import PluginUpdateList from './PluginUpdateList'; 6 | import PluginUpdateProgressTracker from './PluginUpdateProgressTracker'; 7 | 8 | interface PluginUpdateManagerProps { 9 | titleEl: HTMLElement | undefined; 10 | persistPluginSettings: (settings: PluginSettings) => Promise; 11 | closeObsidianTab: () => void; 12 | } 13 | 14 | const ACTION_BAR_LOCATION_MIDDLE = 15 | process.env['OBSIDIAN_APP_ACTION_BAR_LOCATION_MIDDLE'] === 'true'; 16 | 17 | const PluginUpdateManager: React.FC = ({ 18 | titleEl, 19 | persistPluginSettings, 20 | closeObsidianTab, 21 | }) => { 22 | const showUpdateProgressTracker = useAppSelector( 23 | (state) => state.obsidian.isUpdatingPlugins || !state.obsidian.isUpdateResultAcknowledged 24 | ); 25 | 26 | //Action bar is cut-off on iphone https://github.com/swar8080/obsidian-plugin-update-tracker/issues/49 27 | //@ts-ignore 28 | const isPhone = Platform.isPhone; 29 | const isIPhone = Platform.isIosApp && isPhone; 30 | const actionBarLocation = isIPhone || ACTION_BAR_LOCATION_MIDDLE ? 'middle' : 'bottom'; 31 | 32 | if (showUpdateProgressTracker) { 33 | return ; 34 | } else { 35 | return ( 36 | 42 | ); 43 | } 44 | }; 45 | 46 | export default PluginUpdateManager; 47 | -------------------------------------------------------------------------------- /src/components/PluginUpdateProgressTracker.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | import React from 'react'; 3 | import { PluginUpdateResult } from '../state/obsidianReducer'; 4 | import { PluginUpdateProgressTracker } from './PluginUpdateProgressTracker'; 5 | 6 | type Story = ComponentStory; 7 | 8 | const BASE_PLUGIN_UPDATE_PROGRESS_TRACKER = { 9 | onAcknowledgeResults: () => alert('onAcknowledgeResults'), 10 | }; 11 | 12 | const MULTIPLE_RESULTS: PluginUpdateResult[] = [ 13 | { pluginName: 'Plugin1', status: 'failure', pluginId: 'plugin_id' }, 14 | { pluginName: 'Plugin2', status: 'success', pluginId: 'plugin_id' }, 15 | { pluginName: 'Plugin3', status: 'failure', pluginId: 'plugin_id' }, 16 | { 17 | pluginName: 'Very Long Plugin Name That Takes Up Lots of Space', 18 | status: 'failure', 19 | pluginId: 'plugin_id', 20 | }, 21 | { pluginName: 'Medium Length Plugin Name', status: 'failure', pluginId: 'plugin_id' }, 22 | { pluginName: 'Plugin4', status: 'failure', pluginId: 'plugin_id' }, 23 | { pluginName: 'Plugin5', status: 'success', pluginId: 'plugin_id' }, 24 | { pluginName: 'Plugin6', status: 'success', pluginId: 'plugin_id' }, 25 | { pluginName: 'Plugin7', status: 'loading', pluginId: 'plugin_id' }, 26 | ]; 27 | 28 | export const AllUpdatesInProgress_1Plugin: Story = () => ( 29 | 34 | ); 35 | 36 | export const AllUpdatesInProgress_10Plugins: Story = () => ( 37 | 42 | ); 43 | 44 | export const OneUpdateSuccessful: Story = () => ( 45 | 50 | ); 51 | 52 | export const OneUpdateFailed: Story = () => ( 53 | 58 | ); 59 | 60 | export const MultipleResults: Story = () => ( 61 | 66 | ); 67 | 68 | export const CompletedSuccessfullyOnePlugin: Story = () => ( 69 | 74 | ); 75 | 76 | export const CompletedSuccessfullyMultiplePlugins: Story = () => ( 77 | 85 | ); 86 | 87 | export const CompletedWithMultipleResults: Story = () => ( 88 | 93 | ); 94 | 95 | export default { 96 | title: 'PluginUpdateProgressTracker', 97 | component: PluginUpdateProgressTracker, 98 | } as ComponentMeta; 99 | -------------------------------------------------------------------------------- /src/components/PluginUpdateProgressTracker.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import * as React from 'react'; 3 | import styled from 'styled-components'; 4 | import { pluralize } from '../domain/util/pluralize'; 5 | import { useAppDispatch, useAppSelector } from '../state'; 6 | import { acknowledgeUpdateResult } from '../state/actionProducers/acknowledgeUpdateResult'; 7 | import { PluginUpdateResult, PluginUpdateStatus } from '../state/obsidianReducer'; 8 | import { countSelectedPlugins } from '../state/selectors/countSelectedPlugins'; 9 | 10 | interface PluginUpdateProgressTrackerProps { 11 | titleEl: HTMLElement | undefined; 12 | } 13 | 14 | const UPDATE_STATUS_ICON: Record = { 15 | loading: '⌛', 16 | success: '✅', 17 | failure: '❌', 18 | }; 19 | 20 | const PluginUpdateProgressTrackerConnected: React.FC = ({ 21 | titleEl, 22 | }) => { 23 | const updateResults = useAppSelector((state) => state.obsidian.pluginUpdateProgress); 24 | const isUpdatingPlugins = useAppSelector((state) => state.obsidian.isUpdatingPlugins); 25 | const selectedPluginCount = useAppSelector(countSelectedPlugins); 26 | const githubRateLimitTimestamp = useAppSelector( 27 | (state) => state.obsidian.githubRateLimitResetTimestamp 28 | ); 29 | const dispatch = useAppDispatch(); 30 | 31 | React.useEffect(() => { 32 | if (titleEl) { 33 | if (isUpdatingPlugins) { 34 | titleEl.innerText = `Updating ${selectedPluginCount} ${pluralize( 35 | 'Plugin', 36 | selectedPluginCount 37 | )}...`; 38 | } else { 39 | titleEl.innerText = `Finished Updating Plugins`; 40 | } 41 | } 42 | }, [titleEl, selectedPluginCount, isUpdatingPlugins]); 43 | 44 | function onAcknowledgeResults() { 45 | dispatch(acknowledgeUpdateResult()); 46 | } 47 | 48 | return ( 49 | 55 | ); 56 | }; 57 | 58 | export const PluginUpdateProgressTracker: React.FC<{ 59 | updateResults: PluginUpdateResult[]; 60 | isUpdatingPlugins: boolean; 61 | githubRateLimitTimestamp?: number; 62 | onAcknowledgeResults: () => any; 63 | }> = ({ updateResults, isUpdatingPlugins, githubRateLimitTimestamp, onAcknowledgeResults }) => { 64 | const failureCount = updateResults.reduce( 65 | (count, result) => count + (result.status === 'failure' ? 1 : 0), 66 | 0 67 | ); 68 | 69 | let errorInstructions = ''; 70 | if (githubRateLimitTimestamp) { 71 | const time = dayjs(githubRateLimitTimestamp); 72 | errorInstructions = `Your IP address has exceeded github's limit of 60 file downloads in an hour. The limit will reset ${time.fromNow()}, but if that doesn't work then report an issue `; 73 | } else { 74 | errorInstructions = `Try again or report an issue `; 75 | } 76 | 77 | return ( 78 | 79 | {updateResults.map((updateResult) => ( 80 | 81 | ))} 82 | {!isUpdatingPlugins && ( 83 | 84 |
85 | {failureCount === 0 && ( 86 |
87 |

{`${pluralize( 88 | 'Plugin', 89 | updateResults.length 90 | )} successfully installed and reloaded!`}

91 |
92 | )} 93 | {failureCount > 0 && ( 94 |
95 |

{`Completed with ${failureCount} ${pluralize( 96 | 'failure', 97 | failureCount 98 | )}`}

99 |

100 | {errorInstructions} 101 | 105 | here 106 | 107 |

108 |
109 | )} 110 | 111 |
112 | )} 113 |
114 | ); 115 | }; 116 | 117 | const PluginUpdateResultView: React.FC<{ updateResult: PluginUpdateResult }> = ({ 118 | updateResult, 119 | }) => { 120 | return ( 121 | 122 | 123 | {updateResult.pluginName} 124 | 125 | {UPDATE_STATUS_ICON[updateResult.status]} 126 | 127 | 128 | 129 | ); 130 | }; 131 | 132 | const DivPluginUpdateProgressTracker = styled.div``; 133 | 134 | const DivPluginUpdateResult = styled.div` 135 | display: flex; 136 | flex-direction: row; 137 | `; 138 | 139 | const DivPluginUpdateResultText = styled.div``; 140 | 141 | const SpanUpdateResultIcon = styled.span` 142 | padding-left: 0.35rem; 143 | `; 144 | 145 | const DivCompletedNextSteps = styled.div` 146 | hr { 147 | margin: 1rem 0 0.5rem 0; 148 | } 149 | `; 150 | 151 | export default PluginUpdateProgressTrackerConnected; 152 | -------------------------------------------------------------------------------- /src/components/RibbonIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useAppSelector } from '../state'; 3 | import usePluginReleaseFilter from './hooks/usePluginReleaseFilter'; 4 | 5 | interface RibbonIconProps { 6 | rootEl: HTMLElement; 7 | } 8 | 9 | const RibbonIcon: React.FC = ({ rootEl }) => { 10 | const isShownOnMobile = useAppSelector((state) => state.obsidian.settings.showIconOnMobile); 11 | const pluginsWithUpdatesCount = usePluginReleaseFilter().length; 12 | const defaultIconDisplay = React.useRef(rootEl.style.display); 13 | 14 | React.useLayoutEffect(() => { 15 | if (isShownOnMobile && pluginsWithUpdatesCount > 0) { 16 | rootEl.style.display = defaultIconDisplay.current; 17 | } else { 18 | rootEl.style.display = 'none'; 19 | } 20 | }, [isShownOnMobile, pluginsWithUpdatesCount, rootEl]); 21 | 22 | return null; 23 | }; 24 | 25 | export default RibbonIcon; 26 | -------------------------------------------------------------------------------- /src/components/SelectedPluginActionBar.tsx: -------------------------------------------------------------------------------- 1 | import { faCheck } from '@fortawesome/free-solid-svg-icons/faCheck'; 2 | import { faCircleXmark } from '@fortawesome/free-solid-svg-icons/faCircleXmark'; 3 | import { faSpinner } from '@fortawesome/free-solid-svg-icons/faSpinner'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | import * as React from 'react'; 6 | import styled from 'styled-components'; 7 | import { pluralize } from '../domain/util/pluralize'; 8 | 9 | interface SelectedPluginActionBarProps { 10 | numberOfPluginsSelected: number; 11 | isDisabled: boolean; 12 | onClickInstall: () => Promise; 13 | onClickDismissVersions: () => Promise; 14 | hasBottomBorder: boolean; 15 | } 16 | 17 | const LOADING_ANIMATION_SEQUENCE_MS = 1200; 18 | 19 | const ANIMATION_STATE_CONFIG = { 20 | loading: { 21 | icon: faSpinner, 22 | colour: undefined, 23 | text: undefined, 24 | }, 25 | success: { 26 | icon: faCheck, 27 | colour: undefined, 28 | text: undefined, 29 | }, 30 | error: { 31 | icon: faCircleXmark, 32 | colour: '#FF3333', 33 | text: 'Error', 34 | }, 35 | }; 36 | 37 | const SelectedPluginActionBar: React.FC = ({ 38 | numberOfPluginsSelected, 39 | onClickInstall, 40 | onClickDismissVersions, 41 | isDisabled, 42 | hasBottomBorder, 43 | }) => { 44 | const [loadingAnimationState, setLoadingAnimationState] = React.useState( 45 | { 46 | isInProgress: false, 47 | } 48 | ); 49 | const headerText = `${numberOfPluginsSelected} Plugin${ 50 | numberOfPluginsSelected != 1 ? 's' : '' 51 | } Selected`; 52 | const updatePluginText = `Update ${pluralize('Plugin', numberOfPluginsSelected)}`; 53 | 54 | const loadingStateIcon = ANIMATION_STATE_CONFIG[loadingAnimationState.displayIcon || 'loading']; 55 | const disabled = isDisabled || loadingAnimationState.isInProgress; 56 | 57 | function handleActionClick(upstreamActionHandler: () => Promise) { 58 | setLoadingAnimationState({ 59 | isInProgress: true, 60 | displayIcon: 'loading', 61 | }); 62 | 63 | setTimeout(async () => { 64 | let successful = true; 65 | try { 66 | await upstreamActionHandler(); 67 | } catch (err) { 68 | successful = false; 69 | } 70 | 71 | setLoadingAnimationState({ 72 | isInProgress: true, 73 | displayIcon: successful ? 'success' : 'error', 74 | }); 75 | 76 | const nextSequenceLength = successful 77 | ? LOADING_ANIMATION_SEQUENCE_MS 78 | : LOADING_ANIMATION_SEQUENCE_MS * 2; 79 | setTimeout(() => { 80 | setLoadingAnimationState({ 81 | isInProgress: false, 82 | displayIcon: undefined, 83 | }); 84 | }, nextSequenceLength); 85 | }, LOADING_ANIMATION_SEQUENCE_MS); 86 | } 87 | 88 | const statusBarHeight: number = React.useMemo(() => { 89 | const statusBar = document.querySelector('.status-bar') as HTMLElement; 90 | if (statusBar) { 91 | const computedStyle = window.getComputedStyle(statusBar); 92 | return ( 93 | statusBar.offsetHeight + 94 | parseInt(computedStyle.getPropertyValue('margin-top')) + 95 | parseInt(computedStyle.getPropertyValue('margin-bottom')) 96 | ); 97 | } 98 | return 0; 99 | }, []); 100 | const actionButtonPaddingBottomPx = statusBarHeight + 3; 101 | 102 | return ( 103 | 104 | 105 | {!loadingAnimationState.isInProgress && {headerText}} 106 | {loadingAnimationState.isInProgress && ( 107 | <> 108 | {loadingStateIcon.text && {loadingStateIcon.text} } 109 | 114 | 115 | )} 116 | 117 | 118 | 119 | handleActionClick(onClickInstall)} 121 | disabled={disabled} 122 | isDisabled={disabled} 123 | > 124 | {updatePluginText} 125 | 126 | 127 | handleActionClick(onClickDismissVersions)} 129 | disabled={disabled} 130 | isDisabled={disabled} 131 | > 132 | Ignore Version 133 | 134 | 135 | 136 | ); 137 | }; 138 | 139 | type LoadingAnimationState = { 140 | isInProgress: boolean; 141 | displayIcon?: AnimationIcons; 142 | }; 143 | 144 | type AnimationIcons = 'loading' | 'success' | 'error'; 145 | 146 | const CONTAINER_BORDER = '3px var(--background-modifier-border) solid'; 147 | 148 | const DivSelectedPluginActionBarContainer = styled.div<{ hasBottomBorder: boolean }>` 149 | display: flex; 150 | flex-direction: column; 151 | align-items: center; 152 | 153 | padding: 0.5rem 2rem; 154 | 155 | h4 { 156 | margin: 0 0 0.25rem 0; 157 | text-align: center; 158 | } 159 | 160 | background-color: var(--background-secondary); 161 | 162 | border: ${CONTAINER_BORDER}; 163 | border-bottom: ${({ hasBottomBorder }) => (hasBottomBorder ? CONTAINER_BORDER : 'none')}; 164 | `; 165 | 166 | const DivHeaderContainer = styled.div` 167 | margin-bottom: 0.25rem; 168 | font-size: var(--h4-size); 169 | line-height: var(--h4-line-height); 170 | `; 171 | 172 | const H4HeaderText = styled.h4` 173 | margin: 0; 174 | `; 175 | 176 | const DivActionButtonContainer = styled.div<{ paddingBottom: number }>` 177 | display: flex; 178 | flex-direction: row; 179 | align-items: center; 180 | 181 | button { 182 | margin-right: 0.5rem; 183 | } 184 | 185 | button:last-child { 186 | margin-right: 0; 187 | } 188 | 189 | padding-bottom: ${(props) => `${props.paddingBottom}px`}; 190 | `; 191 | 192 | const ButtonAction = styled.button<{ isDisabled: boolean }>` 193 | opacity: ${({ isDisabled }) => (isDisabled ? '0.75' : '1')}; 194 | cursor: ${({ isDisabled }) => (isDisabled ? 'not-allowed' : 'pointer')}; 195 | `; 196 | 197 | export default SelectedPluginActionBar; 198 | -------------------------------------------------------------------------------- /src/components/UpdateStatusIcon.tsx: -------------------------------------------------------------------------------- 1 | import { faPlug } from '@fortawesome/free-solid-svg-icons/faPlug'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import { Notice } from 'obsidian'; 4 | import * as React from 'react'; 5 | import styled from 'styled-components'; 6 | import { useAppDispatch, useAppSelector } from '../state'; 7 | import { fetchReleases } from '../state/actionProducers/fetchReleases'; 8 | import usePluginReleaseFilter from './hooks/usePluginReleaseFilter'; 9 | 10 | interface UpdateStatusIconContainerProps { 11 | onClickViewUpdates: () => any; 12 | parentEl: HTMLElement; 13 | } 14 | 15 | const CSS_CLASS_BASE = 'plugin-update-tracker-icon'; 16 | const TOAST_DELAY_MS = 5000; 17 | 18 | const UpdateStatusIconContainer: React.FC = ({ 19 | onClickViewUpdates, 20 | parentEl, 21 | }) => { 22 | const dispatch = useAppDispatch(); 23 | const isLoading = useAppSelector((state) => state.releases.isLoadingReleases); 24 | const isErrorLoading = useAppSelector((state) => state.releases.isErrorLoadingReleases); 25 | 26 | const pluginsWithUpdates = usePluginReleaseFilter(); 27 | const thisPluginId = useAppSelector((state) => state.obsidian.thisPluginId); 28 | const hasUpdatesForThisPlugin = pluginsWithUpdates.some( 29 | (plugin) => plugin.getPluginId() === thisPluginId 30 | ); 31 | const minUpdateCountToShowIcon = useAppSelector( 32 | (state) => state.obsidian.settings.minUpdateCountToShowIcon 33 | ); 34 | 35 | const defaultParentElDisplay = React.useRef(parentEl.style.display); 36 | React.useLayoutEffect(() => { 37 | if ( 38 | isLoading || 39 | pluginsWithUpdates.length >= minUpdateCountToShowIcon || 40 | hasUpdatesForThisPlugin || 41 | isErrorLoading 42 | ) { 43 | parentEl.style.display = defaultParentElDisplay.current; 44 | } else { 45 | parentEl.style.display = 'none'; 46 | } 47 | }, [ 48 | minUpdateCountToShowIcon, 49 | pluginsWithUpdates.length, 50 | hasUpdatesForThisPlugin, 51 | isLoading, 52 | isErrorLoading, 53 | parentEl, 54 | ]); 55 | 56 | function handlePluginIconClicked() { 57 | if (isLoading) { 58 | return; 59 | } else if (isErrorLoading) { 60 | dispatch(fetchReleases()) 61 | .unwrap() 62 | .catch( 63 | () => 64 | new Notice( 65 | 'Error checking for plugin updates. Please check your internet connection and security settings. Report an issue on github if the issue continues', 66 | TOAST_DELAY_MS 67 | ) 68 | ); 69 | } else if (pluginsWithUpdates.length > 0) { 70 | onClickViewUpdates(); 71 | } else { 72 | new Notice( 73 | "Up-to-date! There aren't any plugin updates ready based on the filters configured in this plugin's settings.", 74 | TOAST_DELAY_MS 75 | ); 76 | } 77 | } 78 | 79 | return ( 80 | <> 81 | 87 | 88 | ); 89 | }; 90 | 91 | type UpdateStatusIconViewProps = { 92 | isLoading: boolean; 93 | isErrorLoading: boolean; 94 | pluginsWithUpdatesCount: number; 95 | onPluginIconClicked: () => any; 96 | }; 97 | 98 | export const UpdateStatusIconView: React.FC = ({ 99 | onPluginIconClicked, 100 | isLoading, 101 | isErrorLoading, 102 | pluginsWithUpdatesCount, 103 | }) => { 104 | const [isMouseOver, setIsMouseOver] = React.useState(false); 105 | 106 | let chipText: string; 107 | let chipColour: string; 108 | let fontSize = '0.55rem'; 109 | let leftOffset: string = '0.05rem'; 110 | let width = '0.5rem'; 111 | let padding = '0.3rem'; 112 | let cursor: string = 'pointer'; 113 | let isPluginUpdatesAvailable = false; 114 | let title; 115 | let cssSelector; 116 | if (isLoading) { 117 | chipText = '⌛'; 118 | chipColour = 'transparent'; 119 | fontSize = '0.45rem'; 120 | leftOffset = '-0.1rem'; 121 | cursor = 'wait'; 122 | title = 'Checking for plugin updates...'; 123 | cssSelector = `${CSS_CLASS_BASE}--loading`; 124 | } else if (isErrorLoading) { 125 | chipText = 'x'; 126 | chipColour = '#FF3333'; 127 | title = 'Error checking for plugin updates - click to retry'; 128 | cursor = 'pointer'; 129 | cssSelector = `${CSS_CLASS_BASE}--error`; 130 | } else if (pluginsWithUpdatesCount > 0) { 131 | chipText = (pluginsWithUpdatesCount || 0).toString(); 132 | chipColour = '#FF4F00'; 133 | padding = '0.3rem'; 134 | leftOffset = '0.08rem'; 135 | if (chipText.length > 1) { 136 | width = '0.65rem'; 137 | padding = '0.3rem 0.4rem'; 138 | } 139 | title = `${pluginsWithUpdatesCount} plugin update${ 140 | pluginsWithUpdatesCount > 1 ? 's' : '' 141 | } available`; 142 | isPluginUpdatesAvailable = true; 143 | cssSelector = `${CSS_CLASS_BASE}--updates-available`; 144 | } else { 145 | chipText = '✓'; 146 | chipColour = '#197300'; 147 | title = 'All plugins up-to-date'; 148 | cssSelector = `${CSS_CLASS_BASE}--no-updates-available`; 149 | cursor = 'default'; 150 | } 151 | 152 | const isHighlighted = isMouseOver && isPluginUpdatesAvailable; 153 | 154 | return ( 155 | setIsMouseOver(true)} 161 | onMouseLeave={() => setIsMouseOver(false)} 162 | isHighlighted={isHighlighted} 163 | className={`${CSS_CLASS_BASE} ${cssSelector}`} 164 | > 165 | 166 | 175 | {chipText} 176 | 177 | 178 | ); 179 | }; 180 | 181 | const DivContainer = styled.div<{ cursor: string; isHighlighted: boolean }>` 182 | height: 100%; 183 | display: flex; 184 | align-items: center; 185 | 186 | cursor: ${(props) => props.cursor}; 187 | user-select: none; 188 | 189 | svg { 190 | color: var(--text-muted); 191 | color: ${(props) => 192 | props.isHighlighted ? 'var(--text-accent-hover)' : 'var(--text-muted)'}; 193 | font-size: 14px; 194 | height: 14px; 195 | } 196 | `; 197 | 198 | const DivPluginStatusChip = styled.div<{ 199 | fontSize: string; 200 | color: string; 201 | leftOffset: string; 202 | width: string; 203 | padding: string; 204 | isHighlighted: boolean; 205 | }>` 206 | display: flex; 207 | justify-content: center; 208 | align-items: center; 209 | position: relative; 210 | line-height: 0.5; 211 | left: ${(props) => props.leftOffset}; 212 | 213 | box-sizing: border-box; 214 | font-size: ${(props) => props.fontSize}; 215 | width: ${(props) => props.width}; 216 | height: 0.6rem; 217 | max-height: var(--icon-size); 218 | max-width: var(--icon-size); 219 | padding: ${(props) => props.padding}; 220 | border-radius: 50%; 221 | 222 | background: ${(props) => props.color}; 223 | color: white; 224 | filter: ${(props) => (props.isHighlighted ? 'brightness(0.75)' : 'brightness(1)')}; 225 | `; 226 | 227 | export default UpdateStatusIconContainer; 228 | -------------------------------------------------------------------------------- /src/components/common/NewTextFadeOutThenInAnimation.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import { useChangeAwareEffect } from 'use-change-aware-effect'; 4 | 5 | interface NewTextFadeInThenOutAnimationProps { 6 | text: string; 7 | } 8 | 9 | export const NewTextFadeInThenOutAnimation: React.FC = ({ 10 | text, 11 | }) => { 12 | const textChangeTracker = React.useRef({ 13 | didChange: false, 14 | previousText: '', 15 | }); 16 | useChangeAwareEffect( 17 | ({ did, previous, isMount }) => { 18 | if (did.text.change && !isMount) { 19 | textChangeTracker.current = { 20 | didChange: true, 21 | previousText: previous.text, 22 | }; 23 | } 24 | }, 25 | { text } 26 | ); 27 | 28 | if (textChangeTracker.current.didChange) { 29 | return ( 30 | 31 | {textChangeTracker.current.previousText} 32 | {text} 33 | 34 | ); 35 | } else { 36 | return {text}; 37 | } 38 | }; 39 | 40 | const ANIMIATION_SECONDS = 1.25; 41 | 42 | const SpanFadeOut = styled.span` 43 | animation-name: fadeOut; 44 | animation-delay: 0s; 45 | animation-duration: ${ANIMIATION_SECONDS}s; 46 | animation-timing-function: ease-out; 47 | animation-fill-mode: forwards; 48 | position: absolute; 49 | 50 | @keyframes fadeOut { 51 | 0% { 52 | opacity: 1; 53 | } 54 | 55 | 100% { 56 | opacity: 0; 57 | } 58 | } 59 | `; 60 | 61 | const SpanFadeIn = styled.span` 62 | opacity: 0; 63 | animation-name: fadeIn; 64 | animation-delay: ${ANIMIATION_SECONDS}s; 65 | animation-duration: ${ANIMIATION_SECONDS}s; 66 | animation-timing-function: ease-out; 67 | animation-fill-mode: forwards; 68 | 69 | @keyframes fadeIn { 70 | 0% { 71 | opacity: 0; 72 | } 73 | 74 | 100% { 75 | opacity: 1; 76 | } 77 | } 78 | `; 79 | -------------------------------------------------------------------------------- /src/components/hooks/usePluginReleaseFilter.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import InstalledPluginReleases from '../../domain/InstalledPluginReleases'; 3 | import pluginFilter, { PluginFilters } from '../../domain/pluginFilter'; 4 | import { useAppSelector } from '../../state'; 5 | 6 | export default function usePluginReleaseFilter( 7 | filters: Partial = {} 8 | ): InstalledPluginReleases[] { 9 | const installed = useAppSelector((state) => state.obsidian.pluginManifests); 10 | const enabledPlugins = useAppSelector((state) => state.obsidian.enabledPlugins); 11 | const releases = useAppSelector((state) => state.releases.releases); 12 | const pluginSettings = useAppSelector((state) => state.obsidian.settings); 13 | 14 | const filteredPlugins = React.useMemo( 15 | () => pluginFilter(filters, pluginSettings, installed, enabledPlugins, releases), 16 | [[...Object.values(filters), pluginSettings, installed, enabledPlugins, releases]] 17 | ); 18 | 19 | return filteredPlugins; 20 | } 21 | -------------------------------------------------------------------------------- /src/domain/InstalledPluginReleases.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import filter from 'lodash/filter'; 3 | import find from 'lodash/find'; 4 | import map from 'lodash/map'; 5 | import orderBy from 'lodash/orderBy'; 6 | import { PluginManifest } from 'obsidian'; 7 | import { PluginFileAssetIds, PluginReleases, ReleaseVersion } from 'oput-common'; 8 | export default class InstalledPluginReleases { 9 | private plugin: PluginManifest; 10 | private releases: PluginReleases | undefined; 11 | 12 | public static create( 13 | plugins: PluginManifest[], 14 | releases: PluginReleases[] 15 | ): InstalledPluginReleases[] { 16 | const releasesByPluginId = releases.reduce((map, p) => { 17 | map[p.obsidianPluginId] = p; 18 | return map; 19 | }, {} as Record); 20 | 21 | return map( 22 | plugins, 23 | (plugin) => new InstalledPluginReleases(plugin, releasesByPluginId[plugin.id]) 24 | ); 25 | } 26 | 27 | private constructor(plugin: PluginManifest, releases: PluginReleases | undefined) { 28 | this.plugin = plugin; 29 | 30 | if (releases?.newVersions) { 31 | //sort releases in descending order 32 | releases = { 33 | ...releases, 34 | newVersions: orderBy( 35 | releases.newVersions, 36 | (release) => release.publishedAt, 37 | 'desc' 38 | ), 39 | }; 40 | } 41 | this.releases = releases; 42 | } 43 | 44 | public keepReleaseVersions(keepFilter: (version: ReleaseVersion) => boolean): void { 45 | if (this.releases) { 46 | this.releases.newVersions = filter(this.releases.newVersions, keepFilter); 47 | } 48 | } 49 | 50 | public getReleaseVersions(): ReleaseVersion[] { 51 | return this.releases?.newVersions || []; 52 | } 53 | 54 | public getPluginId(): string { 55 | return this.plugin.id; 56 | } 57 | 58 | public getPluginName(): string { 59 | return this.plugin.name; 60 | } 61 | 62 | public getInstalledVersionNumber(): string { 63 | return this.plugin.version; 64 | } 65 | 66 | public getLatestVersionNumber(): string { 67 | const newReleaseVersion = this.getNewReleaseVersion(); 68 | if (newReleaseVersion) { 69 | return newReleaseVersion.versionNumber; 70 | } 71 | return this.plugin.version; 72 | } 73 | 74 | public getLatestUpdateTime(): dayjs.Dayjs | undefined { 75 | const newReleaseVersion = this.getNewReleaseVersion(); 76 | if (newReleaseVersion) { 77 | return dayjs(newReleaseVersion.updatedAt); 78 | } 79 | } 80 | 81 | public getLatestDownloads(): number { 82 | const newReleaseVersion = this.getNewReleaseVersion(); 83 | return newReleaseVersion?.downloads || 0; 84 | } 85 | 86 | public getPluginRepositoryUrl(): string { 87 | return this.releases?.pluginRepositoryUrl || ''; 88 | } 89 | 90 | public getLatestReleaseAssetIds(): PluginFileAssetIds | undefined { 91 | const newReleaseVersion = this.getNewReleaseVersion(); 92 | return newReleaseVersion?.fileAssetIds; 93 | } 94 | 95 | public getReleaseAssetIdsForVersion(versionNumber: string): PluginFileAssetIds | undefined { 96 | const release = find( 97 | this.releases?.newVersions, 98 | (release) => release.versionNumber == versionNumber 99 | ); 100 | return release?.fileAssetIds; 101 | } 102 | 103 | public isLatestVersionABetaVersion(): boolean { 104 | const newReleaseVersion = this.getNewReleaseVersion(); 105 | return newReleaseVersion?.isBetaVersion === true; 106 | } 107 | 108 | public getRepoPath(): string | undefined { 109 | return this.releases?.pluginRepoPath; 110 | } 111 | 112 | private getNewReleaseVersion(): ReleaseVersion | undefined { 113 | if (this.releases?.newVersions.length) { 114 | return this.releases.newVersions[0]; 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/domain/api.ts: -------------------------------------------------------------------------------- 1 | import { request, requestUrl } from 'obsidian'; 2 | import { NewPluginVersionRequest, PluginReleases } from 'oput-common'; 3 | 4 | type ReleaseApi = (request: NewPluginVersionRequest) => Promise; 5 | 6 | const BACKEND_API_URL = 7 | (process.env['OBSIDIAN_APP_UPDATE_CHECKER_URL'] || '') + '/obsidian-plugin-update-tracker'; 8 | 9 | export const getReleases: ReleaseApi = async (newPluginVersionRequest: NewPluginVersionRequest) => { 10 | try { 11 | const res: string = await request({ 12 | url: BACKEND_API_URL, 13 | method: 'POST', 14 | body: JSON.stringify(newPluginVersionRequest), 15 | }); 16 | return JSON.parse(res); 17 | } catch (err) { 18 | console.warn( 19 | `Failed checking for plugin updates at ${BACKEND_API_URL}. Check your internet connection and security settings or file a bug at https://github.com/swar8080/obsidian-plugin-update-tracker/issues.\nError details are:\n`, 20 | err 21 | ); 22 | throw err; 23 | } 24 | }; 25 | 26 | export const getReleaseAsset = async ( 27 | assetId: number, 28 | githubRepo: string 29 | ): Promise<{ success: boolean; fileContents?: string; rateLimitResetTimestamp?: number }> => { 30 | const res = await requestUrl({ 31 | url: `https://api.github.com/repos/${githubRepo}/releases/assets/${assetId}`, 32 | method: 'GET', 33 | headers: { Accept: 'application/octet-stream' }, 34 | throw: false, 35 | }); 36 | 37 | if (res.status === 200) { 38 | const fileContents = res.text; 39 | return { success: true, fileContents }; 40 | } else if ( 41 | res.headers['x-ratelimit-remaining'] === '0' && 42 | res.headers['x-ratelimit-reset'] != null 43 | ) { 44 | const rateLimitResetTimestamp = parseInt(res.headers['x-ratelimit-reset']) * 1000; 45 | return { success: false, rateLimitResetTimestamp }; 46 | } else { 47 | throw res; 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /src/domain/initiatePluginSettings.test.ts: -------------------------------------------------------------------------------- 1 | import initiatePluginSettings from './initiatePluginSettings'; 2 | import { DEFAULT_PLUGIN_SETTINGS, PluginSettings } from './pluginSettings'; 3 | 4 | describe('initiatePluginSettings', () => { 5 | test('loading plugin for first time with null existing settings uses default settings', () => { 6 | const settings = initiatePluginSettings(null); 7 | 8 | expect(settings).toEqual(DEFAULT_PLUGIN_SETTINGS); 9 | }); 10 | 11 | test('loading plugin for first time uses default settings', () => { 12 | const settings = initiatePluginSettings({}); 13 | 14 | expect(settings).toEqual(DEFAULT_PLUGIN_SETTINGS); 15 | }); 16 | 17 | test('loading saved settings', () => { 18 | const savedSettings: PluginSettings = { 19 | daysToSuppressNewUpdates: DEFAULT_PLUGIN_SETTINGS.daysToSuppressNewUpdates + 1, 20 | dismissedVersionsByPluginId: { 21 | plugin1: { 22 | pluginId: 'plugin1', 23 | pluginRepoPath: 'author/plugin1', 24 | dismissedVersions: [], 25 | }, 26 | }, 27 | showIconOnMobile: !DEFAULT_PLUGIN_SETTINGS.showIconOnMobile, 28 | showNotificationOnNewUpdate: !DEFAULT_PLUGIN_SETTINGS.showNotificationOnNewUpdate, 29 | excludeBetaVersions: !DEFAULT_PLUGIN_SETTINGS.excludeBetaVersions, 30 | excludeDisabledPlugins: !DEFAULT_PLUGIN_SETTINGS.excludeDisabledPlugins, 31 | minUpdateCountToShowIcon: DEFAULT_PLUGIN_SETTINGS.minUpdateCountToShowIcon + 1, 32 | hoursBetweenCheckingForUpdates: 33 | DEFAULT_PLUGIN_SETTINGS.hoursBetweenCheckingForUpdates + 1, 34 | }; 35 | 36 | const settings = initiatePluginSettings(savedSettings); 37 | 38 | expect(settings).toEqual(savedSettings); 39 | }); 40 | 41 | describe('migrating hideIconIfNoUpdatesAvailable', () => { 42 | test('previously showing if no updates available', () => { 43 | const savedSettings: Partial = { 44 | hideIconIfNoUpdatesAvailable: false, 45 | }; 46 | 47 | const settings = initiatePluginSettings(savedSettings); 48 | 49 | expect(settings.minUpdateCountToShowIcon).toBe(0); 50 | expect(settings.hideIconIfNoUpdatesAvailable).toBe(false); 51 | }); 52 | 53 | test('previously hiding if no updates available', () => { 54 | const savedSettings: Partial = { 55 | hideIconIfNoUpdatesAvailable: true, 56 | }; 57 | 58 | const settings = initiatePluginSettings(savedSettings); 59 | 60 | expect(settings.minUpdateCountToShowIcon).toBe(1); 61 | expect(settings.hideIconIfNoUpdatesAvailable).toBe(true); 62 | }); 63 | 64 | test('ignore hideIconIfNoUpdatesAvailable if minUpdateCountToShowIcon is already populated', () => { 65 | const savedSettings: Partial = { 66 | hideIconIfNoUpdatesAvailable: true, 67 | minUpdateCountToShowIcon: 15, 68 | }; 69 | 70 | const settings = initiatePluginSettings(savedSettings); 71 | 72 | expect(settings.minUpdateCountToShowIcon).toBe(15); 73 | expect(settings.hideIconIfNoUpdatesAvailable).toBe(true); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/domain/initiatePluginSettings.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_PLUGIN_SETTINGS, PluginSettings } from './pluginSettings'; 2 | 3 | export default function initiateSettings( 4 | savedSettings: Partial | null 5 | ): PluginSettings { 6 | savedSettings = savedSettings || {}; 7 | const migratedSettings: Partial = {}; 8 | 9 | if ( 10 | savedSettings.hideIconIfNoUpdatesAvailable !== undefined && 11 | savedSettings.minUpdateCountToShowIcon === undefined 12 | ) { 13 | migratedSettings.minUpdateCountToShowIcon = savedSettings.hideIconIfNoUpdatesAvailable 14 | ? 1 15 | : 0; 16 | } 17 | 18 | return Object.assign({}, DEFAULT_PLUGIN_SETTINGS, savedSettings, migratedSettings); 19 | } 20 | -------------------------------------------------------------------------------- /src/domain/pluginFilter.test.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import { PluginManifest } from 'obsidian'; 3 | import { PluginReleases, ReleaseVersion } from '../../oput-common'; 4 | import InstalledPluginReleases from './InstalledPluginReleases'; 5 | import pluginFilter, { PluginFilters } from './pluginFilter'; 6 | import { DEFAULT_PLUGIN_SETTINGS, DismissedPluginVersion, PluginSettings } from './pluginSettings'; 7 | 8 | describe('pluginFilter', () => { 9 | let id = 1000; 10 | 11 | const PREVIOUS_PLUGIN_VERSION = '0.99.99'; 12 | const INSTALLED_PLUGIN_VERSION = '1.0.0'; 13 | const INSTALLED_PLUGIN_ID = 'plugin 1 id'; 14 | const COMPATIBLE_APP_VERSION = '15.0.0'; 15 | const INCOMPATIBLE_APP_VERSION = '16.0.0'; 16 | 17 | const NEW_PLUGIN_VERSION = '1.0.' + ++id; 18 | const NEW_PLUGIN_VERSION_PUBLISHED_DATE = '2022-06-15T6:00:00Z'; 19 | const NEW_PLUGIN_VERSION_UPDATED_AT = '2022-06-16T6:00:00Z'; 20 | 21 | let pluginSettings: PluginSettings; 22 | const PLUGIN_SETTINGS_BASE: PluginSettings = { 23 | ...DEFAULT_PLUGIN_SETTINGS, 24 | daysToSuppressNewUpdates: 0, 25 | dismissedVersionsByPluginId: {}, 26 | showIconOnMobile: true, 27 | excludeDisabledPlugins: false, 28 | excludeBetaVersions: false, 29 | }; 30 | 31 | let pluginManifests: PluginManifest[]; 32 | const PLUGIN_MANIFEST_BASE: PluginManifest = { 33 | id: INSTALLED_PLUGIN_ID, 34 | version: INSTALLED_PLUGIN_VERSION, 35 | minAppVersion: COMPATIBLE_APP_VERSION, 36 | name: 'Plugin1', 37 | author: '', 38 | description: '', 39 | }; 40 | 41 | let enabledPlugins: Record; 42 | 43 | const PLUGIN_NEW_RELEASE_VERSION_BASE: ReleaseVersion = { 44 | releaseId: id++, 45 | versionName: 'v1.0.1', 46 | versionNumber: NEW_PLUGIN_VERSION, 47 | minObsidianAppVersion: COMPATIBLE_APP_VERSION, 48 | notes: 'release notes', 49 | areNotesTruncated: false, 50 | downloads: 123, 51 | publishedAt: NEW_PLUGIN_VERSION_PUBLISHED_DATE, 52 | isBetaVersion: false, 53 | fileAssetIds: { 54 | mainJs: id++, 55 | manifestJson: id++, 56 | }, 57 | updatedAt: NEW_PLUGIN_VERSION_UPDATED_AT, 58 | }; 59 | const PLUGIN_NEW_RELEASES_BASE: Omit = { 60 | obsidianPluginId: INSTALLED_PLUGIN_ID, 61 | pluginName: 'Plugin1', 62 | pluginRepositoryUrl: 'https://github.com/author1/some-plugin', 63 | pluginRepoPath: 'author1/some-plugin', 64 | }; 65 | let pluginReleases: PluginReleases[]; 66 | 67 | const DISABLED_FILTERS: PluginFilters = { 68 | excludeDisabledPlugins: false, 69 | excludeBetaVersions: false, 70 | excludeDismissed: false, 71 | excludeIncompatibleVersions: false, 72 | excludeTooRecentUpdates: false, 73 | }; 74 | 75 | beforeEach(() => { 76 | pluginSettings = { ...PLUGIN_SETTINGS_BASE }; 77 | pluginManifests = [{ ...PLUGIN_MANIFEST_BASE }]; 78 | enabledPlugins = { [INSTALLED_PLUGIN_ID]: true }; 79 | pluginReleases = [ 80 | { 81 | ...PLUGIN_NEW_RELEASES_BASE, 82 | newVersions: [{ ...PLUGIN_NEW_RELEASE_VERSION_BASE }], 83 | }, 84 | ]; 85 | }); 86 | 87 | describe('semver filtering', () => { 88 | it('includes versions with greater semver versions as installed', () => { 89 | const result = testWithSemverVersion(NEW_PLUGIN_VERSION); 90 | 91 | expect(result).toHaveLength(1); 92 | }); 93 | 94 | it('ignores versions from an earlier semver version than installed', () => { 95 | const result = testWithSemverVersion(PREVIOUS_PLUGIN_VERSION); 96 | 97 | expect(result).toHaveLength(0); 98 | }); 99 | 100 | it('ignores versions with the same semver version as installed', () => { 101 | const result = testWithSemverVersion(INSTALLED_PLUGIN_VERSION); 102 | 103 | expect(result).toHaveLength(0); 104 | }); 105 | 106 | it('ignores versions without a semver version', () => { 107 | const result = testWithSemverVersion(null); 108 | 109 | expect(result).toHaveLength(0); 110 | }); 111 | 112 | function testWithSemverVersion(version: string | null): InstalledPluginReleases[] { 113 | //@ts-ignore 114 | pluginReleases[0].newVersions[0].versionNumber = version; 115 | 116 | return pluginFilter( 117 | { ...DISABLED_FILTERS }, 118 | pluginSettings, 119 | pluginManifests, 120 | enabledPlugins, 121 | pluginReleases 122 | ); 123 | } 124 | }); 125 | 126 | describe('dismissed version filtering', () => { 127 | const DISMISSED_VERSION_BASE: Omit = { 128 | versionName: '', 129 | publishedAt: '', 130 | }; 131 | const SOME_OTHER_PLUGIN_ID = 'some other plugin id'; 132 | 133 | beforeEach(() => { 134 | pluginSettings.dismissedVersionsByPluginId = { 135 | [INSTALLED_PLUGIN_ID]: { 136 | pluginRepoPath: '', 137 | pluginId: INSTALLED_PLUGIN_ID, 138 | dismissedVersions: [], 139 | }, 140 | [SOME_OTHER_PLUGIN_ID]: { 141 | pluginRepoPath: '', 142 | pluginId: SOME_OTHER_PLUGIN_ID, 143 | dismissedVersions: [], 144 | }, 145 | }; 146 | }); 147 | 148 | it('ignores versions that are dismissed', () => { 149 | pluginSettings.dismissedVersionsByPluginId[INSTALLED_PLUGIN_ID].dismissedVersions.push({ 150 | versionNumber: NEW_PLUGIN_VERSION, 151 | ...DISMISSED_VERSION_BASE, 152 | }); 153 | 154 | const result = testCase(); 155 | 156 | expect(result).toHaveLength(0); 157 | }); 158 | 159 | it('includes versions when a different version was dismissed', () => { 160 | pluginSettings.dismissedVersionsByPluginId[INSTALLED_PLUGIN_ID].dismissedVersions.push({ 161 | versionNumber: PREVIOUS_PLUGIN_VERSION, 162 | ...DISMISSED_VERSION_BASE, 163 | }); 164 | 165 | const result = testCase(); 166 | 167 | expect(result).toHaveLength(1); 168 | }); 169 | 170 | it('includes versions when a different plugin dismissed the same version', () => { 171 | pluginSettings.dismissedVersionsByPluginId[SOME_OTHER_PLUGIN_ID].dismissedVersions.push( 172 | { 173 | versionNumber: NEW_PLUGIN_VERSION, 174 | ...DISMISSED_VERSION_BASE, 175 | } 176 | ); 177 | 178 | const result = testCase(); 179 | 180 | expect(result).toHaveLength(1); 181 | }); 182 | 183 | function testCase(): InstalledPluginReleases[] { 184 | return pluginFilter( 185 | { ...DISABLED_FILTERS, excludeDismissed: true }, 186 | pluginSettings, 187 | pluginManifests, 188 | enabledPlugins, 189 | pluginReleases 190 | ); 191 | } 192 | }); 193 | 194 | describe('version compatability filter', () => { 195 | test('all are compatible', () => { 196 | const result = testWithVersionCompatability( 197 | COMPATIBLE_APP_VERSION, 198 | COMPATIBLE_APP_VERSION 199 | ); 200 | 201 | expect(result).toHaveLength(1); 202 | expect(result[0].getReleaseVersions()).toHaveLength(2); 203 | }); 204 | 205 | test('no versions are compatible', () => { 206 | const result = testWithVersionCompatability( 207 | INCOMPATIBLE_APP_VERSION, 208 | INCOMPATIBLE_APP_VERSION 209 | ); 210 | 211 | expect(result).toHaveLength(0); 212 | }); 213 | 214 | test('only latest is compatible', () => { 215 | const result = testWithVersionCompatability( 216 | COMPATIBLE_APP_VERSION, 217 | INCOMPATIBLE_APP_VERSION 218 | ); 219 | 220 | expect(result).toHaveLength(1); 221 | expect(result[0].getReleaseVersions()).toHaveLength(1); 222 | expect(result[0].getLatestVersionNumber()).toBe( 223 | pluginReleases[0].newVersions[0].versionNumber 224 | ); 225 | }); 226 | 227 | test('only previous is compatible', () => { 228 | const result = testWithVersionCompatability( 229 | INCOMPATIBLE_APP_VERSION, 230 | COMPATIBLE_APP_VERSION 231 | ); 232 | 233 | expect(result).toHaveLength(1); 234 | expect(result[0].getReleaseVersions()).toHaveLength(1); 235 | expect(result[0].getLatestVersionNumber()).toBe( 236 | pluginReleases[0].newVersions[1].versionNumber 237 | ); 238 | }); 239 | 240 | test('missing compatibility version is considered compatible', () => { 241 | const result = testWithVersionCompatability(undefined, undefined); 242 | 243 | expect(result).toHaveLength(1); 244 | expect(result[0].getReleaseVersions()).toHaveLength(2); 245 | }); 246 | 247 | function testWithVersionCompatability( 248 | newReleaseCompatibleObsidianVersion: string | undefined, 249 | previousReleaseCompatibleObsidianVersion: string | undefined 250 | ): InstalledPluginReleases[] { 251 | pluginReleases[0].newVersions[0].minObsidianAppVersion = 252 | previousReleaseCompatibleObsidianVersion; 253 | 254 | const nextVersion = buildNextVersion(); 255 | nextVersion.minObsidianAppVersion = newReleaseCompatibleObsidianVersion; 256 | pluginReleases[0].newVersions = [nextVersion, ...pluginReleases[0].newVersions]; 257 | 258 | return pluginFilter( 259 | { ...DISABLED_FILTERS, excludeIncompatibleVersions: true }, 260 | pluginSettings, 261 | pluginManifests, 262 | enabledPlugins, 263 | pluginReleases 264 | ); 265 | } 266 | }); 267 | 268 | describe('plugin enablement filter', () => { 269 | test('plugin enablement filter override takes precedent over settings', () => { 270 | testCase({ pluginEnabled: true, excludeDisabled: true, isIncluded: true }); 271 | testCase({ pluginEnabled: true, excludeDisabled: false, isIncluded: true }); 272 | testCase({ pluginEnabled: false, excludeDisabled: true, isIncluded: false }); 273 | testCase({ pluginEnabled: false, excludeDisabled: false, isIncluded: true }); 274 | }); 275 | 276 | function testCase(params: { 277 | pluginEnabled: boolean; 278 | excludeDisabled: boolean; 279 | isIncluded: boolean; 280 | }) { 281 | enabledPlugins[INSTALLED_PLUGIN_ID] = params.pluginEnabled; 282 | 283 | const result = pluginFilter( 284 | { ...DISABLED_FILTERS, excludeDisabledPlugins: params.excludeDisabled }, 285 | pluginSettings, 286 | pluginManifests, 287 | enabledPlugins, 288 | pluginReleases 289 | ); 290 | 291 | expect(result).toHaveLength(params.isIncluded ? 1 : 0); 292 | } 293 | 294 | test("enablement plugin setting is used when filter override isn't provided", () => { 295 | enabledPlugins[INSTALLED_PLUGIN_ID] = false; 296 | let filterOverride = { ...DISABLED_FILTERS }; 297 | //@ts-ignore 298 | delete filterOverride['excludeDisabledPlugins']; 299 | pluginSettings.excludeDisabledPlugins = true; 300 | 301 | let result = pluginFilter( 302 | filterOverride, 303 | pluginSettings, 304 | pluginManifests, 305 | enabledPlugins, 306 | pluginReleases 307 | ); 308 | 309 | expect(result).toHaveLength(0); 310 | 311 | pluginSettings.excludeDisabledPlugins = false; 312 | 313 | result = pluginFilter( 314 | filterOverride, 315 | pluginSettings, 316 | pluginManifests, 317 | enabledPlugins, 318 | pluginReleases 319 | ); 320 | 321 | expect(result).toHaveLength(1); 322 | }); 323 | }); 324 | 325 | describe('days since update filter', () => { 326 | const now = dayjs(); 327 | 328 | test('all versions updated too recently', () => { 329 | const result = testCase({ 330 | latestVersionUpdatedAt: now.subtract(10, 'minutes'), 331 | previousVersionUpdatedAt: now.subtract(20, 'minutes'), 332 | daysToWaitForUpdates: 1, 333 | }); 334 | 335 | expect(result).toHaveLength(0); 336 | }); 337 | 338 | test('all versions updated long ago enough', () => { 339 | const result = testCase({ 340 | latestVersionUpdatedAt: now.subtract(1, 'day').subtract(1, 'second'), 341 | previousVersionUpdatedAt: now.subtract(20, 'days'), 342 | daysToWaitForUpdates: 1, 343 | }); 344 | 345 | expect(result).toHaveLength(1); 346 | expect(result[0].getReleaseVersions()).toHaveLength(2); 347 | }); 348 | 349 | test('most recent version updated too recently, use previous version', () => { 350 | const result = testCase({ 351 | latestVersionUpdatedAt: now.subtract(1, 'day').add(1, 'second'), 352 | previousVersionUpdatedAt: now.subtract(20, 'days'), 353 | daysToWaitForUpdates: 1, 354 | }); 355 | 356 | expect(result).toHaveLength(1); 357 | expect(result[0].getReleaseVersions()).toHaveLength(1); 358 | expect(result[0].getReleaseVersions()[0].versionNumber).toBe( 359 | pluginReleases[0].newVersions[1].versionNumber 360 | ); 361 | }); 362 | 363 | test('previous version updated too recently, use more recent version', () => { 364 | const result = testCase({ 365 | latestVersionUpdatedAt: now.subtract(20, 'day'), 366 | previousVersionUpdatedAt: now.subtract(20, 'minutes'), 367 | daysToWaitForUpdates: 1, 368 | }); 369 | 370 | expect(result).toHaveLength(1); 371 | expect(result[0].getReleaseVersions()).toHaveLength(1); 372 | expect(result[0].getReleaseVersions()[0].versionNumber).toBe( 373 | pluginReleases[0].newVersions[0].versionNumber 374 | ); 375 | }); 376 | 377 | function testCase(params: { 378 | latestVersionUpdatedAt: dayjs.Dayjs; 379 | previousVersionUpdatedAt: dayjs.Dayjs; 380 | daysToWaitForUpdates: number; 381 | }): InstalledPluginReleases[] { 382 | const newVersion = buildNextVersion(); 383 | newVersion.updatedAt = params.latestVersionUpdatedAt.format(); 384 | 385 | const prevVersion = pluginReleases[0].newVersions[0]; 386 | prevVersion.updatedAt = params.previousVersionUpdatedAt.format(); 387 | 388 | pluginReleases[0].newVersions = [newVersion, prevVersion]; 389 | 390 | pluginSettings.daysToSuppressNewUpdates = params.daysToWaitForUpdates; 391 | 392 | return pluginFilter( 393 | { ...DISABLED_FILTERS, excludeTooRecentUpdates: true }, 394 | pluginSettings, 395 | pluginManifests, 396 | enabledPlugins, 397 | pluginReleases, 398 | now 399 | ); 400 | } 401 | }); 402 | 403 | it('ignores versions without known asset ids', () => { 404 | pluginReleases[0].newVersions[0].fileAssetIds = undefined; 405 | 406 | const result = pluginFilter( 407 | { ...DISABLED_FILTERS }, 408 | pluginSettings, 409 | pluginManifests, 410 | enabledPlugins, 411 | pluginReleases 412 | ); 413 | 414 | expect(result).toHaveLength(0); 415 | }); 416 | 417 | function buildNextVersion(): ReleaseVersion { 418 | const nextVersion = '1.0.' + id++; 419 | return { 420 | ...PLUGIN_NEW_RELEASE_VERSION_BASE, 421 | releaseId: id++, 422 | versionNumber: nextVersion, 423 | versionName: 'v' + nextVersion, 424 | }; 425 | } 426 | }); 427 | -------------------------------------------------------------------------------- /src/domain/pluginFilter.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import { PluginManifest, requireApiVersion } from 'obsidian'; 3 | import { PluginSettings } from './pluginSettings'; 4 | 5 | import { PluginReleases } from 'oput-common'; 6 | import { semverCompare } from '../../oput-common/semverCompare'; 7 | import InstalledPluginReleases from './InstalledPluginReleases'; 8 | 9 | const HIDE_THIS_PLUGINS_UPDATES = process.env['OBSIDIAN_APP_HIDE_THIS_PLUGINS_UPDATES'] === 'true'; 10 | const THIS_PLUGIN_ID = process.env['OBSIDIAN_APP_THIS_PLUGIN_ID']; 11 | 12 | export type PluginFilters = { 13 | excludeDismissed: boolean; 14 | excludeTooRecentUpdates: boolean; 15 | excludeIncompatibleVersions: boolean; 16 | excludeBetaVersions: boolean; 17 | excludeDisabledPlugins: boolean; 18 | }; 19 | 20 | export const DEFAULT_FILTERS: Omit< 21 | PluginFilters, 22 | 'excludeDisabledPlugins' | 'excludeBetaVersions' 23 | > = { 24 | excludeDismissed: true, 25 | excludeTooRecentUpdates: true, 26 | excludeIncompatibleVersions: true, 27 | }; 28 | 29 | const filter = ( 30 | filterOverrides: Partial, 31 | pluginSettings: PluginSettings, 32 | installedPlugins: PluginManifest[], 33 | enabledPlugins: Record | undefined, 34 | releases: PluginReleases[], 35 | now: dayjs.Dayjs = dayjs() 36 | ): InstalledPluginReleases[] => { 37 | const filters = Object.assign( 38 | { 39 | excludeDisabledPlugins: pluginSettings.excludeDisabledPlugins, 40 | excludeBetaVersions: pluginSettings.excludeBetaVersions, 41 | }, 42 | DEFAULT_FILTERS, 43 | filterOverrides 44 | ); 45 | const allPlugins = InstalledPluginReleases.create(installedPlugins, releases); 46 | const isPluginVersionDismissed = buildDismissedPluginVersionMemo(pluginSettings); 47 | 48 | return allPlugins.filter((plugin) => { 49 | let include = true; 50 | 51 | //Mutate/filter out versions 52 | plugin.keepReleaseVersions((version) => { 53 | if ( 54 | filters.excludeIncompatibleVersions && 55 | version.minObsidianAppVersion != null && 56 | !requireApiVersion(version.minObsidianAppVersion) 57 | ) { 58 | return false; 59 | } 60 | 61 | if ( 62 | filters.excludeTooRecentUpdates && 63 | pluginSettings.daysToSuppressNewUpdates > 0 && 64 | now.diff(version.updatedAt, 'days') < pluginSettings.daysToSuppressNewUpdates 65 | ) { 66 | return false; 67 | } 68 | 69 | if ( 70 | filters.excludeDismissed && 71 | isPluginVersionDismissed(plugin.getPluginId(), version.versionNumber) 72 | ) { 73 | return false; 74 | } 75 | 76 | if (filters.excludeBetaVersions && version.isBetaVersion) { 77 | return false; 78 | } 79 | 80 | if (!version.fileAssetIds) { 81 | return false; 82 | } 83 | 84 | if (semverCompare(version.versionNumber, plugin.getInstalledVersionNumber()) <= 0) { 85 | return false; 86 | } 87 | 88 | return true; 89 | }); 90 | 91 | const newVersions = plugin.getReleaseVersions(); 92 | if (newVersions.length == 0) { 93 | include = false; 94 | } 95 | 96 | if (filters.excludeDisabledPlugins && enabledPlugins) { 97 | include = include && enabledPlugins[plugin.getPluginId()] === true; 98 | } 99 | 100 | if (HIDE_THIS_PLUGINS_UPDATES && THIS_PLUGIN_ID) { 101 | include = include && plugin.getPluginId() !== THIS_PLUGIN_ID; 102 | } 103 | 104 | return include; 105 | }); 106 | }; 107 | 108 | function buildDismissedPluginVersionMemo( 109 | settings: PluginSettings 110 | ): (pluginId: string, versionNumber: string) => boolean { 111 | const idVersionSet: Set = new Set(); 112 | 113 | const pluginIds = Object.keys(settings.dismissedVersionsByPluginId); 114 | for (const pluginId of pluginIds) { 115 | settings.dismissedVersionsByPluginId[pluginId].dismissedVersions.forEach( 116 | (dismissedVersion) => idVersionSet.add(pluginId + dismissedVersion.versionNumber) 117 | ); 118 | } 119 | 120 | return (pluginId, versionNumber) => idVersionSet.has(pluginId + versionNumber); 121 | } 122 | 123 | export default filter; 124 | -------------------------------------------------------------------------------- /src/domain/pluginSettings.ts: -------------------------------------------------------------------------------- 1 | export type PluginSettings = { 2 | daysToSuppressNewUpdates: number; 3 | dismissedVersionsByPluginId: Record; 4 | excludeBetaVersions: boolean; 5 | excludeDisabledPlugins: boolean; 6 | showIconOnMobile: boolean; 7 | showNotificationOnNewUpdate: boolean; 8 | // Deprecated for minUpdateCountToShowIcon 9 | hideIconIfNoUpdatesAvailable?: boolean; 10 | minUpdateCountToShowIcon: number; 11 | hoursBetweenCheckingForUpdates: number; 12 | }; 13 | 14 | export type PluginDismissedVersions = { 15 | pluginId: string; 16 | pluginRepoPath: string; 17 | dismissedVersions: DismissedPluginVersion[]; 18 | }; 19 | 20 | export type DismissedPluginVersion = { 21 | versionNumber: string; 22 | versionName: string; 23 | publishedAt: string; 24 | }; 25 | 26 | export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = { 27 | daysToSuppressNewUpdates: 0, 28 | dismissedVersionsByPluginId: {}, 29 | showIconOnMobile: true, 30 | showNotificationOnNewUpdate: false, 31 | excludeBetaVersions: true, 32 | excludeDisabledPlugins: false, 33 | minUpdateCountToShowIcon: 0, 34 | hoursBetweenCheckingForUpdates: 0.5, 35 | }; 36 | -------------------------------------------------------------------------------- /src/domain/releaseNoteEnricher.test.ts: -------------------------------------------------------------------------------- 1 | import enrichReleaseNotes from './releaseNoteEnricher'; 2 | 3 | describe('releaseNoteEnricher', () => { 4 | const VERSION_NAME = 'Release v1.0.1*'; 5 | const VERSION_NUMBER = '1.1.1'; 6 | const PLUGIN_URL = 'https://github.com/author/repo-name'; 7 | 8 | describe('Removing redundant release name', () => { 9 | test('Remove name from start of notes', () => { 10 | const notes = `# ${VERSION_NAME}\n# Details of ${VERSION_NAME}\nEtc`; 11 | 12 | const result = enrichReleaseNotes(notes, VERSION_NAME, VERSION_NUMBER, PLUGIN_URL); 13 | 14 | expect(result).toBe(`# Details of ${VERSION_NAME}\nEtc`); 15 | }); 16 | 17 | test('Name not removed from middle of notes', () => { 18 | const notes = `# Some other header\n# Details of ${VERSION_NAME}\nEtc`; 19 | 20 | const result = enrichReleaseNotes(notes, VERSION_NAME, VERSION_NUMBER, PLUGIN_URL); 21 | 22 | expect(result).toBe(notes); 23 | }); 24 | 25 | test('Name removed from non-markdown header', () => { 26 | const notes = `${VERSION_NAME}\n\n# Details of ${VERSION_NAME}\nEtc`; 27 | 28 | const result = enrichReleaseNotes(notes, VERSION_NAME, VERSION_NUMBER, PLUGIN_URL); 29 | 30 | expect(result).toBe(`# Details of ${VERSION_NAME}\nEtc`); 31 | }); 32 | }); 33 | 34 | describe('Remove redundant release version', () => { 35 | test('Remove version from start of notes', () => { 36 | const notes = `## ${VERSION_NUMBER}\n# Details of ${VERSION_NUMBER}\nEtc`; 37 | 38 | const result = enrichReleaseNotes(notes, VERSION_NAME, VERSION_NUMBER, PLUGIN_URL); 39 | 40 | expect(result).toBe(`# Details of ${VERSION_NUMBER}\nEtc`); 41 | }); 42 | 43 | test('Version not removed from middle of notes', () => { 44 | const notes = `## Some other header\n# Details of ${VERSION_NUMBER}\nEtc`; 45 | 46 | const result = enrichReleaseNotes(notes, VERSION_NAME, VERSION_NUMBER, PLUGIN_URL); 47 | 48 | expect(result).toBe(notes); 49 | }); 50 | }); 51 | 52 | describe('Replacing github issue numbers with links to the issue', () => { 53 | it('Replaces all likely issue numbers', () => { 54 | let notes = '#233 Fixed issue #421: Also #4332. Then #123, #456 and #789! Finally #999'; 55 | let result = enrichReleaseNotes(notes, VERSION_NAME, VERSION_NUMBER, PLUGIN_URL); 56 | expect(result).toBe( 57 | '[#233](https://github.com/author/repo-name/issues/233) Fixed issue [#421](https://github.com/author/repo-name/issues/421): Also [#4332](https://github.com/author/repo-name/issues/4332). Then [#123](https://github.com/author/repo-name/issues/123), [#456](https://github.com/author/repo-name/issues/456) and [#789](https://github.com/author/repo-name/issues/789)! Finally [#999](https://github.com/author/repo-name/issues/999)' 58 | ); 59 | }); 60 | 61 | it('does not replace values partially resembling an issue number', () => { 62 | const notes = '123 #123a #'; 63 | const result = enrichReleaseNotes(notes, VERSION_NAME, VERSION_NUMBER, PLUGIN_URL); 64 | expect(result).toBe(notes); 65 | }); 66 | 67 | it('does not replace issue numbers that already have a url', () => { 68 | const notes = '[#123](https://github.com/author/some-other-repo/issues/123)'; 69 | const result = enrichReleaseNotes(notes, VERSION_NAME, VERSION_NUMBER, PLUGIN_URL); 70 | expect(result).toBe(notes); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/domain/releaseNoteEnricher.ts: -------------------------------------------------------------------------------- 1 | const REFERENCED_GITHUB_ISSUE_PATTERN = /#(\d+)([\s:,.!]|$)/g; 2 | 3 | export default function enrichReleaseNotes( 4 | notes: string, 5 | versionName: string, 6 | versionNumber: string, 7 | pluginRepoUrl: string 8 | ): string { 9 | const repeatedVersionNameRemovalRegex = new RegExp( 10 | `^#*\\s?${escapeStringRegexp(versionName)}\n*` 11 | ); 12 | notes = notes.replace(repeatedVersionNameRemovalRegex, ''); 13 | 14 | const repeatedVersionNumberRemovalRegex = new RegExp( 15 | `^#*\\s?${escapeStringRegexp(versionNumber)}\n*` 16 | ); 17 | notes = notes.replace(repeatedVersionNumberRemovalRegex, ''); 18 | 19 | notes = notes.replace(REFERENCED_GITHUB_ISSUE_PATTERN, `[#$1](${pluginRepoUrl}/issues/$1)$2`); 20 | 21 | return notes; 22 | } 23 | 24 | function escapeStringRegexp(string: string) { 25 | return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); 26 | } 27 | -------------------------------------------------------------------------------- /src/domain/util/groupById.ts: -------------------------------------------------------------------------------- 1 | import isFunction from 'lodash/isFunction'; 2 | 3 | export function groupById( 4 | items: T[], 5 | idExtractor: keyof T | ((item: T) => string) 6 | ): Record { 7 | if (items == null || items.length === 0) { 8 | return {}; 9 | } 10 | return items.reduce((combined, item) => { 11 | let id: string; 12 | if (isFunction(idExtractor)) { 13 | id = idExtractor(item); 14 | } else { 15 | id = new String(item[idExtractor]).toString(); 16 | } 17 | 18 | combined[id] = item; 19 | return combined; 20 | }, {} as Record); 21 | } 22 | -------------------------------------------------------------------------------- /src/domain/util/pluralize.ts: -------------------------------------------------------------------------------- 1 | export function pluralize(text: string, count: number) { 2 | return count !== 1 ? text + 's' : text; 3 | } 4 | -------------------------------------------------------------------------------- /src/domain/util/semverCompare.test.ts: -------------------------------------------------------------------------------- 1 | import { semverCompare } from '../../../oput-common/semverCompare'; 2 | 3 | describe('semverCompare', () => { 4 | type EXPECTED = 'greater' | 'equal' | 'less'; 5 | 6 | test('it', () => { 7 | testCase(null, null, 'equal'); 8 | testCase('1.0.0', '1.0.0', 'equal'); 9 | testCase('1.0.0', '1.0.00', 'equal'); 10 | testCase('01.0.0', '1.0.00', 'equal'); 11 | testCase('1.2.3', '1.2.3beta.4', 'equal'); 12 | testCase('2.0.0', '2', 'equal'); 13 | testCase('2.0.0', '2.0', 'equal'); 14 | 15 | testCase('2.0.0', '1.0.0', 'greater'); 16 | testCase('1.2.0', '1.1.0', 'greater'); 17 | testCase('1.2.3', '1.2.2', 'greater'); 18 | testCase('1.2.30', '1.2.9', 'greater'); 19 | testCase('1.2.4beta', '1.2.3', 'greater'); 20 | testCase('1.2.4beta.0', '1.2.3', 'greater'); 21 | testCase('1.2.4.beta.0', '1.2.3', 'greater'); 22 | testCase('1.2.1', '1.2', 'greater'); 23 | testCase('1.2', '1.1.9', 'greater'); 24 | 25 | testCase('1.0.0', '2.0.0', 'less'); 26 | testCase('1.1.0', '1.2.0', 'less'); 27 | testCase('1.2.2', '1.2.3', 'less'); 28 | testCase('1.2.9', '1.2.30', 'less'); 29 | testCase('1.2.3', '1.2.4beta', 'less'); 30 | testCase('1.2.3', '1.2.4beta.0', 'less'); 31 | testCase('1.2.3', '1.2.4.beta.0', 'less'); 32 | testCase('1', '2.0.1', 'less'); 33 | testCase('1.9', '1.9.1', 'less'); 34 | }); 35 | 36 | function testCase(v1: string | null, v2: string | null, expected: EXPECTED) { 37 | const result = semverCompare(v1, v2); 38 | 39 | if (expected === 'equal') { 40 | expect(result).toBe(0); 41 | } else if (expected === 'greater') { 42 | expect(result).toBeGreaterThan(0); 43 | } else { 44 | expect(result).toBeLessThan(0); 45 | } 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /src/domain/util/sleep.ts: -------------------------------------------------------------------------------- 1 | export function sleep(ms: number): Promise { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /src/state/actionProducers/acknowledgeUpdateResult.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit'; 2 | import find from 'lodash/find'; 3 | import { State } from '..'; 4 | import pluginFilter from '../../domain/pluginFilter'; 5 | import { PLUGIN_UPDATES_MANAGER_VIEW_TYPE } from '../../main'; 6 | import { acknowledgedPluginUpdateResults, ObsidianApp } from '../obsidianReducer'; 7 | 8 | export const acknowledgeUpdateResult = createAsyncThunk( 9 | 'obsidian/acknowledgeUpdateResult', 10 | async (_: void, thunkAPI) => { 11 | const state = thunkAPI.getState() as State; 12 | const app = window.app as ObsidianApp; 13 | 14 | const didUpdateSelf = 15 | find( 16 | state.obsidian.pluginUpdateProgress, 17 | (updateResult) => 18 | updateResult.pluginId === state.obsidian.thisPluginId && 19 | updateResult.status === 'success' 20 | ) != null; 21 | if (didUpdateSelf && app.plugins?.disablePlugin && app.plugins?.enablePlugin) { 22 | //restart this plugin 23 | await app.plugins.disablePlugin(state.obsidian.thisPluginId); 24 | await app.plugins.enablePlugin(state.obsidian.thisPluginId); 25 | return; 26 | } 27 | 28 | thunkAPI.dispatch(acknowledgedPluginUpdateResults()); 29 | 30 | //Close the view if there's no plugins left to be updated 31 | const remainingPluginsWithUpdates = pluginFilter( 32 | {}, 33 | state.obsidian.settings, 34 | state.obsidian.pluginManifests, 35 | state.obsidian.enabledPlugins, 36 | state.releases.releases 37 | ); 38 | if (remainingPluginsWithUpdates.length === 0) { 39 | app.workspace.detachLeavesOfType(PLUGIN_UPDATES_MANAGER_VIEW_TYPE); 40 | } 41 | } 42 | ); 43 | -------------------------------------------------------------------------------- /src/state/actionProducers/cleanupDismissedPluginVersions.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit'; 2 | 3 | import { State } from '..'; 4 | import { semverCompare } from '../../../oput-common/semverCompare'; 5 | import { PluginDismissedVersions, PluginSettings } from '../../domain/pluginSettings'; 6 | import { groupById } from '../../domain/util/groupById'; 7 | 8 | type Paramters = { 9 | persistPluginSettings: (settings: PluginSettings) => Promise; 10 | }; 11 | 12 | export const cleanupDismissedPluginVersions = createAsyncThunk( 13 | 'releases/cleanupDismissedPluginVersions', 14 | async (params: Paramters, thunkAPI) => { 15 | try { 16 | const state = thunkAPI.getState() as State; 17 | 18 | const manifests = state.obsidian.pluginManifests; 19 | const manifestById = groupById(manifests, 'id'); 20 | 21 | const settings = state.obsidian.settings; 22 | const cleanedSettings: PluginSettings = { 23 | ...settings, 24 | dismissedVersionsByPluginId: Object.keys( 25 | settings.dismissedVersionsByPluginId 26 | ).reduce((combined, pluginId) => { 27 | const cleanedDismissedVersions: PluginDismissedVersions = { 28 | ...settings.dismissedVersionsByPluginId[pluginId], 29 | dismissedVersions: settings.dismissedVersionsByPluginId[ 30 | pluginId 31 | ].dismissedVersions.filter((dismissedVersion) => { 32 | const installedVersion = manifestById[pluginId]?.version; 33 | return ( 34 | !installedVersion || 35 | semverCompare(dismissedVersion.versionNumber, installedVersion) > 0 36 | ); 37 | }), 38 | }; 39 | 40 | combined[pluginId] = cleanedDismissedVersions; 41 | 42 | return combined; 43 | }, {} as Record), 44 | }; 45 | 46 | await params.persistPluginSettings(cleanedSettings); 47 | } catch (err) { 48 | console.error('Error cleaning up dismissed plugin versions', err); 49 | } 50 | } 51 | ); 52 | -------------------------------------------------------------------------------- /src/state/actionProducers/dismissPluginVersions.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit'; 2 | import dayjs from 'dayjs'; 3 | import filter from 'lodash/filter'; 4 | import find from 'lodash/find'; 5 | import { State } from '..'; 6 | import { 7 | DismissedPluginVersion, 8 | PluginDismissedVersions, 9 | PluginSettings 10 | } from '../../domain/pluginSettings'; 11 | import { groupById } from '../../domain/util/groupById'; 12 | import { ObsidianState } from '../obsidianReducer'; 13 | import { ReleaseState } from '../releasesReducer'; 14 | 15 | type Paramters = { 16 | pluginVersionsToDismiss: PluginVersionsToDismiss; 17 | persistPluginSettings: (settings: PluginSettings) => Promise; 18 | }; 19 | 20 | export type PluginVersionsToDismiss = { 21 | pluginId: string; 22 | pluginVersionNumber: string; 23 | isLastAvailableVersion: boolean; 24 | }[]; 25 | 26 | export const dismissSelectedPluginVersions = createAsyncThunk( 27 | 'releases/dismissPluginVersions', 28 | async (params: Paramters, thunkAPI) => { 29 | const state = thunkAPI.getState() as State; 30 | const obsidianState = state.obsidian as ObsidianState; 31 | const releaseState = state.releases as ReleaseState; 32 | 33 | const currentSettings: PluginSettings = obsidianState.settings; 34 | const pluginReleasesById = groupById(releaseState.releases, 'obsidianPluginId'); 35 | const selectedPluginVersionsById = groupById(params.pluginVersionsToDismiss, 'pluginId'); 36 | const selectedPluginIds: string[] = Object.keys(selectedPluginVersionsById); 37 | 38 | const dismissedVersionsByPluginId: Record = { 39 | ...currentSettings.dismissedVersionsByPluginId, 40 | }; 41 | 42 | for (const pluginId of selectedPluginIds) { 43 | const versionNumber = selectedPluginVersionsById[pluginId].pluginVersionNumber; 44 | const releases = pluginReleasesById[pluginId]; 45 | const relesaeVersion = find( 46 | releases.newVersions, 47 | (releaseVersion) => releaseVersion.versionNumber === versionNumber 48 | ); 49 | 50 | let pluginDismissedVersions: PluginDismissedVersions = dismissedVersionsByPluginId[ 51 | pluginId 52 | ] || { 53 | pluginId, 54 | pluginRepoPath: releases.pluginRepoPath, 55 | dismissedVersions: [], 56 | }; 57 | 58 | const dismissedVersionsWithoutSelected: DismissedPluginVersion[] = 59 | filter( 60 | pluginDismissedVersions.dismissedVersions, 61 | (dismissedVersion) => dismissedVersion.versionNumber !== versionNumber 62 | ) || []; 63 | const dismissedVersion: DismissedPluginVersion = { 64 | versionNumber, 65 | versionName: relesaeVersion?.versionName || versionNumber, 66 | publishedAt: relesaeVersion?.publishedAt || dayjs().format(), 67 | }; 68 | dismissedVersionsByPluginId[pluginId] = { 69 | ...pluginDismissedVersions, 70 | dismissedVersions: [dismissedVersion, ...dismissedVersionsWithoutSelected], 71 | }; 72 | } 73 | 74 | const updatedSettings: PluginSettings = { 75 | ...obsidianState.settings, 76 | dismissedVersionsByPluginId, 77 | }; 78 | await params.persistPluginSettings(updatedSettings); 79 | 80 | return params.pluginVersionsToDismiss; 81 | } 82 | ); 83 | -------------------------------------------------------------------------------- /src/state/actionProducers/fetchReleases.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit'; 2 | import { NewPluginVersionRequest } from 'oput-common'; 3 | import { State } from '..'; 4 | import { getReleases } from '../../domain/api'; 5 | import { ObsidianState } from '../obsidianReducer'; 6 | 7 | export const fetchReleases = createAsyncThunk('releases/fetch', async (_: void, thunkAPI) => { 8 | const state = thunkAPI.getState() as State; 9 | const obsidianState = state.obsidian as ObsidianState; 10 | 11 | const request: NewPluginVersionRequest = { 12 | currentPluginVersions: obsidianState.pluginManifests.map((manifest) => ({ 13 | obsidianPluginId: manifest.id, 14 | version: manifest.version, 15 | })), 16 | }; 17 | 18 | return await getReleases(request); 19 | }); 20 | -------------------------------------------------------------------------------- /src/state/actionProducers/showUpdateNotification.ts: -------------------------------------------------------------------------------- 1 | import PluginUpdateCheckerPlugin from 'src/main'; 2 | import { store } from '..'; 3 | import { ObsidianApp } from '../obsidianReducer'; 4 | import { fetchReleases } from './fetchReleases'; 5 | 6 | export const showUpdateNotificationMiddleware = () => (next: any) => (action: any) => { 7 | const result = next(action); 8 | 9 | if (action.type === fetchReleases.fulfilled.type) { 10 | const state = store.getState(); 11 | const thisPluginId = state.obsidian.thisPluginId; 12 | 13 | const app = window.app as ObsidianApp; 14 | const plugin = app.plugins?.plugins?.[thisPluginId] as PluginUpdateCheckerPlugin; 15 | 16 | if (plugin) { 17 | // Bind the method to preserve the this context 18 | plugin.showNotificationOnNewUpdate.bind(plugin)(); 19 | } 20 | } 21 | 22 | return result; 23 | }; 24 | -------------------------------------------------------------------------------- /src/state/actionProducers/syncApp.ts: -------------------------------------------------------------------------------- 1 | import values from 'lodash/values'; 2 | import { App } from 'obsidian'; 3 | import { ObsidianApp, syncPluginManifests } from '../obsidianReducer'; 4 | 5 | export const syncApp = (app: App) => { 6 | const obsidianApp = app as ObsidianApp; 7 | const manifests = values(obsidianApp.plugins?.manifests) || []; 8 | 9 | let enabledPlugins: Record | undefined = {}; 10 | 11 | const pluginIds = manifests.map((manifest) => manifest.id); 12 | const persistedAsEnabledSet = 13 | obsidianApp.plugins?.enabledPlugins instanceof Set 14 | ? obsidianApp.plugins.enabledPlugins 15 | : undefined; 16 | 17 | for (let pluginId of pluginIds) { 18 | const currentlyEnabled = obsidianApp.plugins?.plugins?.[pluginId]?._loaded; 19 | if (currentlyEnabled != undefined) { 20 | // This is needed to work with plugins like lazy-plugin which seems to persist a disabled status and temporarily enable without persisting. 21 | enabledPlugins[pluginId] = currentlyEnabled; 22 | } else if (persistedAsEnabledSet) { 23 | // Fallback in case undocumented _loaded API changes 24 | enabledPlugins[pluginId] = persistedAsEnabledSet.has(pluginId); 25 | } else { 26 | console.warn( 27 | 'Unable to determine enabled plugins, obsidian APIs may have changed! Please file a bug report at https://github.com/swar8080/obsidian-plugin-update-tracker.' 28 | ); 29 | } 30 | } 31 | 32 | return syncPluginManifests({ manifests, enabledPlugins }); 33 | }; 34 | -------------------------------------------------------------------------------- /src/state/actionProducers/undismissPluginVersion.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit'; 2 | import filter from 'lodash/filter'; 3 | import { State } from '..'; 4 | import { PluginSettings } from '../../domain/pluginSettings'; 5 | 6 | type Paramters = { 7 | pluginId: string; 8 | versionNumber: string; 9 | persistPluginSettings: (settings: PluginSettings) => Promise; 10 | }; 11 | 12 | export const unDismissPluginVersion = createAsyncThunk( 13 | 'releases/unDismissPluginVersions', 14 | async (params: Paramters, thunkAPI) => { 15 | const { pluginId, versionNumber, persistPluginSettings } = params; 16 | const state = thunkAPI.getState() as State; 17 | let settings = state.obsidian.settings; 18 | const dismissedVersionsByPluginId = settings.dismissedVersionsByPluginId; 19 | 20 | if (pluginId in dismissedVersionsByPluginId) { 21 | settings = { 22 | ...settings, 23 | dismissedVersionsByPluginId: { 24 | ...settings.dismissedVersionsByPluginId, 25 | [pluginId]: { 26 | ...settings.dismissedVersionsByPluginId[pluginId], 27 | dismissedVersions: 28 | filter( 29 | settings.dismissedVersionsByPluginId[pluginId].dismissedVersions, 30 | (dismissedVersion) => 31 | dismissedVersion.versionNumber !== versionNumber 32 | ) || [], 33 | }, 34 | }, 35 | }; 36 | 37 | await persistPluginSettings(settings); 38 | } 39 | } 40 | ); 41 | -------------------------------------------------------------------------------- /src/state/actionProducers/updatePlugins.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, ThunkDispatch } from '@reduxjs/toolkit'; 2 | import { normalizePath } from 'obsidian'; 3 | import { State } from '..'; 4 | import { getReleaseAsset } from '../../domain/api'; 5 | import InstalledPluginReleases from '../../domain/InstalledPluginReleases'; 6 | import { sleep } from '../../domain/util/sleep'; 7 | import { githubRateLimit, ObsidianApp, pluginUpdateStatusChange } from '../obsidianReducer'; 8 | import { getSelectedPluginIds } from '../selectors/getSelectedPluginIds'; 9 | import { syncApp } from './syncApp'; 10 | 11 | const SIMULATE_UPDATE_PLUGINS = process.env['OBSIDIAN_APP_SIMULATE_UPDATE_PLUGINS'] === 'true'; 12 | 13 | type Params = { 14 | visiblePluginVersionsById: Record; 15 | }; 16 | 17 | export const updatePlugins = createAsyncThunk( 18 | 'obsidian/updatePlugins', 19 | async (params: Params, thunkAPI) => { 20 | const dispatch = thunkAPI.dispatch; 21 | const app = window.app as ObsidianApp; 22 | 23 | // Update cached values that may be stale before retrieving state 24 | await dispatch(syncApp(app)); 25 | const state = thunkAPI.getState() as State; 26 | 27 | const allPlugins = InstalledPluginReleases.create( 28 | state.obsidian.pluginManifests, 29 | state.releases.releases 30 | ); 31 | const allPluginsById = allPlugins.reduce((combined, plugin) => { 32 | combined[plugin.getPluginId()] = plugin; 33 | return combined; 34 | }, {} as Record); 35 | 36 | const selectedPluginIds = getSelectedPluginIds(state); 37 | 38 | let isRateLimited = false; 39 | for (const pluginId of selectedPluginIds) { 40 | const installedPlugin = allPluginsById[pluginId]; 41 | const versionToInstall = params.visiblePluginVersionsById[pluginId]; 42 | const versionReleastAssetIds = 43 | installedPlugin.getReleaseAssetIdsForVersion(versionToInstall); 44 | const pluginRepoPath = installedPlugin.getRepoPath(); 45 | const isPluginEnabled = 46 | state.obsidian.enabledPlugins != null && state.obsidian.enabledPlugins[pluginId]; 47 | const isUpdatingThisPlugin = pluginId === state.obsidian.thisPluginId; 48 | 49 | let success: boolean; 50 | let didDisable = false; 51 | try { 52 | dispatch( 53 | pluginUpdateStatusChange({ 54 | pluginId, 55 | pluginName: installedPlugin.getPluginName(), 56 | status: 'loading', 57 | }) 58 | ); 59 | 60 | //Just to be safe, since these are undocumented apis 61 | if ( 62 | !app.plugins?.disablePlugin || 63 | !app.plugins?.enablePlugin || 64 | !app.plugins?.loadManifests 65 | ) { 66 | throw new Error('missing obsidian api'); 67 | } 68 | if (!versionReleastAssetIds?.mainJs || !versionReleastAssetIds?.manifestJson) { 69 | throw new Error('missing asset ids'); 70 | } 71 | if (!pluginRepoPath) { 72 | throw new Error('missing github repository path'); 73 | } 74 | 75 | if (isRateLimited) { 76 | success = false; 77 | } else if (!SIMULATE_UPDATE_PLUGINS) { 78 | //download and install seperately to reduce the chances of only some of the new files being written to disk 79 | const [mainJs, manifestJson, styleCss] = await Promise.all([ 80 | downloadPluginFile(versionReleastAssetIds.mainJs, pluginRepoPath, dispatch), 81 | downloadPluginFile( 82 | versionReleastAssetIds.manifestJson, 83 | pluginRepoPath, 84 | dispatch 85 | ), 86 | downloadPluginFile( 87 | versionReleastAssetIds.styleCss, 88 | pluginRepoPath, 89 | dispatch 90 | ), 91 | ]); 92 | 93 | if (!isUpdatingThisPlugin) { 94 | // Wait for any other queued/in-progress reloads to finish, based on https://github.com/pjeby/hot-reload/blob/master/main.js 95 | await app.plugins.disablePlugin(pluginId); 96 | didDisable = true; 97 | } 98 | 99 | await Promise.all([ 100 | installPluginFile(pluginId, 'main.js', mainJs), 101 | installManifestFile( 102 | pluginId, 103 | 'manifest.json', 104 | manifestJson, 105 | versionToInstall 106 | ), 107 | installPluginFile(pluginId, 'styles.css', styleCss), 108 | ]); 109 | success = true; 110 | } else { 111 | await sleep(Math.random() * 5000); 112 | success = Math.random() > 0.2; 113 | } 114 | 115 | //thanks https://github.com/TfTHacker/obsidian42-brat/blob/main/src/features/BetaPlugins.ts 116 | await app.plugins.loadManifests(); 117 | if (isPluginEnabled && didDisable) { 118 | await app.plugins.enablePlugin(pluginId); 119 | } 120 | } catch (err) { 121 | console.error('Error updating ' + pluginId, err); 122 | success = false; 123 | 124 | if (err instanceof GithubRateLimitError) { 125 | isRateLimited = true; 126 | } 127 | 128 | if (isPluginEnabled && didDisable && app.plugins?.enablePlugin) { 129 | await app.plugins.enablePlugin(pluginId); 130 | } 131 | } 132 | 133 | dispatch( 134 | pluginUpdateStatusChange({ 135 | pluginId, 136 | pluginName: installedPlugin.getPluginName(), 137 | status: success ? 'success' : 'failure', 138 | }) 139 | ); 140 | } 141 | 142 | //update snapshot of plugin manifests which also will filter out the plugins that are now up-to-date 143 | dispatch(syncApp(app)); 144 | } 145 | ); 146 | 147 | async function downloadPluginFile( 148 | assetId: number | undefined, 149 | gitRepoPath: string, 150 | dispatch: ThunkDispatch 151 | ): Promise { 152 | if (!assetId) { 153 | return ''; 154 | } 155 | const result = await getReleaseAsset(assetId, gitRepoPath); 156 | 157 | if (result.success) { 158 | return result.fileContents || ''; 159 | } 160 | 161 | if (result.rateLimitResetTimestamp) { 162 | dispatch(githubRateLimit(result.rateLimitResetTimestamp)); 163 | throw new GithubRateLimitError(); 164 | } 165 | throw new Error('Unexpected error fetching file ' + assetId); 166 | } 167 | 168 | class GithubRateLimitError extends Error {} 169 | 170 | async function installManifestFile( 171 | pluginId: string, 172 | fileName: string, 173 | fileContents: string, 174 | versionNumber: string 175 | ) { 176 | /** 177 | * Do not trust the version number in the downloaded manifest file as some beta releases (installed using something like BART) use a previous version number. 178 | * Instead, use the github version number. 179 | * An example is periodic-notes version 1.0.0-beta.3 that uses version 0.0.17 in its manifest. 180 | */ 181 | const manifestJson = JSON.parse(fileContents); 182 | let contents = fileContents; 183 | // only update the manifest.json if changes need to be made 184 | if (manifestJson.version !== versionNumber) { 185 | manifestJson.version = versionNumber; 186 | contents = JSON.stringify(manifestJson, null, '\t'); 187 | } 188 | await installPluginFile(pluginId, fileName, contents); 189 | } 190 | 191 | async function installPluginFile(pluginId: string, fileName: string, fileContents: string) { 192 | const configDir = normalizePath(app.vault.configDir); 193 | const filePath = `${configDir}/plugins/${pluginId}/${fileName}`; 194 | await app.vault.adapter.write(filePath, fileContents); 195 | } 196 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction, combineReducers, configureStore, ThunkAction } from '@reduxjs/toolkit'; 2 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; 3 | import logger from 'redux-logger'; 4 | import { showUpdateNotificationMiddleware } from './actionProducers/showUpdateNotification'; 5 | import ObsidianReducer, { ObsidianState } from './obsidianReducer'; 6 | import ReleaseReducer, { ReleaseState } from './releasesReducer'; 7 | 8 | const reducers = combineReducers({ 9 | obsidian: ObsidianReducer, 10 | releases: ReleaseReducer, 11 | }); 12 | 13 | export const RESET_ACTION = { type: 'RESET' }; 14 | 15 | export const store = configureStore({ 16 | reducer: (state, action) => { 17 | if (action?.type === RESET_ACTION.type) { 18 | return reducers(undefined, action); 19 | } 20 | return reducers(state, action); 21 | }, 22 | middleware: (getDefaultMiddleware) => { 23 | const middleware = getDefaultMiddleware(); 24 | if (process.env.OBSIDIAN_APP_ENABLE_REDUX_LOGGER === 'true') { 25 | middleware.push(logger); 26 | } 27 | middleware.push(showUpdateNotificationMiddleware); 28 | return middleware; 29 | }, 30 | }); 31 | 32 | export type State = { 33 | obsidian: ObsidianState; 34 | releases: ReleaseState; 35 | }; 36 | type Dispatcher = typeof store.dispatch; 37 | 38 | export const useAppDispatch: () => Dispatcher = useDispatch; 39 | export const useAppSelector: TypedUseSelectorHook = useSelector; 40 | 41 | export type AppThunk = ThunkAction; 42 | -------------------------------------------------------------------------------- /src/state/obsidianReducer.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import find from 'lodash/find'; 3 | import { App, PluginManifest } from 'obsidian'; 4 | import { DEFAULT_PLUGIN_SETTINGS, PluginSettings } from '../domain/pluginSettings'; 5 | import { dismissSelectedPluginVersions } from './actionProducers/dismissPluginVersions'; 6 | import { updatePlugins } from './actionProducers/updatePlugins'; 7 | 8 | export type ObsidianApp = App & { 9 | plugins?: { 10 | manifests?: Record; 11 | enabledPlugins?: Set; 12 | plugins?: Record< 13 | string, 14 | { 15 | _loaded?: boolean; 16 | } 17 | >; 18 | disablePlugin?: (pluginId: string) => Promise; 19 | enablePlugin?: (pluginId: string) => Promise; 20 | loadManifests?: () => Promise; 21 | }; 22 | }; 23 | 24 | export type ObsidianState = { 25 | thisPluginId: string; 26 | pluginManifests: PluginManifest[]; 27 | enabledPlugins?: Record; 28 | settings: PluginSettings; 29 | selectedPluginsById: Record; 30 | isUpdatingPlugins: boolean; 31 | pluginUpdateProgress: PluginUpdateResult[]; 32 | isUpdateResultAcknowledged: boolean; 33 | githubRateLimitResetTimestamp?: number; 34 | }; 35 | 36 | const DEFAULT_STATE: ObsidianState = { 37 | thisPluginId: '', 38 | pluginManifests: [], 39 | enabledPlugins: undefined, 40 | settings: DEFAULT_PLUGIN_SETTINGS, 41 | selectedPluginsById: {}, 42 | isUpdatingPlugins: false, 43 | pluginUpdateProgress: [], 44 | isUpdateResultAcknowledged: true, 45 | }; 46 | 47 | export type PluginUpdateResult = { 48 | pluginId: string; 49 | pluginName: string; 50 | status: PluginUpdateStatus; 51 | }; 52 | 53 | export type PluginUpdateStatus = 'loading' | 'success' | 'failure'; 54 | 55 | const obsidianStateSlice = createSlice({ 56 | name: 'obsidian', 57 | initialState: DEFAULT_STATE, 58 | reducers: { 59 | syncPluginManifests( 60 | state, 61 | action: PayloadAction<{ 62 | manifests: PluginManifest[]; 63 | enabledPlugins?: Record; 64 | }> 65 | ) { 66 | state.pluginManifests = action.payload.manifests; 67 | state.enabledPlugins = action.payload.enabledPlugins; 68 | }, 69 | syncThisPluginId(state, action: PayloadAction) { 70 | state.thisPluginId = action.payload; 71 | }, 72 | syncSettings(state, action: PayloadAction) { 73 | state.settings = action.payload; 74 | }, 75 | pluginUpdateStatusChange(state, action: PayloadAction) { 76 | const update = action.payload; 77 | 78 | const existing = find( 79 | state.pluginUpdateProgress, 80 | (plugin) => plugin.pluginName === update.pluginName 81 | ); 82 | if (existing) { 83 | existing.status = update.status; 84 | } else { 85 | state.pluginUpdateProgress.push(update); 86 | } 87 | }, 88 | acknowledgedPluginUpdateResults(state) { 89 | state.isUpdateResultAcknowledged = true; 90 | }, 91 | togglePluginSelection( 92 | state, 93 | action: PayloadAction<{ pluginId: string; selected: boolean }> 94 | ) { 95 | const { pluginId, selected } = action.payload; 96 | state.selectedPluginsById[pluginId] = selected; 97 | }, 98 | toggleSelectAllPlugins( 99 | state, 100 | action: PayloadAction<{ select: boolean; pluginIds: string[] }> 101 | ) { 102 | const { select, pluginIds } = action.payload; 103 | state.selectedPluginsById = {}; 104 | 105 | if (select) { 106 | pluginIds.forEach((pluginId) => (state.selectedPluginsById[pluginId] = true)); 107 | } 108 | }, 109 | githubRateLimit(state, action: PayloadAction) { 110 | state.githubRateLimitResetTimestamp = action.payload; 111 | }, 112 | }, 113 | extraReducers: (builder) => { 114 | builder 115 | .addCase(updatePlugins.pending, (state) => { 116 | state.isUpdatingPlugins = true; 117 | state.pluginUpdateProgress = []; 118 | state.isUpdateResultAcknowledged = false; 119 | state.githubRateLimitResetTimestamp = undefined; 120 | }) 121 | .addCase(updatePlugins.fulfilled, (state) => { 122 | state.isUpdatingPlugins = false; 123 | state.selectedPluginsById = {}; 124 | }) 125 | .addCase(updatePlugins.rejected, (state) => { 126 | state.isUpdatingPlugins = false; 127 | state.selectedPluginsById = {}; 128 | }) 129 | .addCase(dismissSelectedPluginVersions.fulfilled, (state, action) => { 130 | for (const dismissedVersion of action.payload) { 131 | state.selectedPluginsById[dismissedVersion.pluginId] = 132 | !dismissedVersion.isLastAvailableVersion; 133 | } 134 | }); 135 | }, 136 | }); 137 | 138 | export const { 139 | syncThisPluginId, 140 | syncSettings, 141 | syncPluginManifests, 142 | togglePluginSelection, 143 | toggleSelectAllPlugins, 144 | pluginUpdateStatusChange, 145 | acknowledgedPluginUpdateResults, 146 | githubRateLimit, 147 | } = obsidianStateSlice.actions; 148 | export default obsidianStateSlice.reducer; 149 | -------------------------------------------------------------------------------- /src/state/releasesReducer.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import { PluginReleases } from 'oput-common'; 3 | import { cleanupDismissedPluginVersions } from './actionProducers/cleanupDismissedPluginVersions'; 4 | import { dismissSelectedPluginVersions } from './actionProducers/dismissPluginVersions'; 5 | import { fetchReleases } from './actionProducers/fetchReleases'; 6 | import { unDismissPluginVersion } from './actionProducers/undismissPluginVersion'; 7 | 8 | export type ReleaseState = { 9 | isLoadingReleases: boolean; 10 | isErrorLoadingReleases: boolean; 11 | releases: PluginReleases[]; 12 | isUpdatingDismissedVersions: boolean; 13 | }; 14 | 15 | const DEFAULT_STATE: ReleaseState = { 16 | isLoadingReleases: false, 17 | isErrorLoadingReleases: false, 18 | releases: [], 19 | isUpdatingDismissedVersions: false, 20 | }; 21 | 22 | const releaseReducer = createSlice({ 23 | name: 'release', 24 | initialState: DEFAULT_STATE, 25 | reducers: {}, 26 | extraReducers: (builder) => { 27 | builder 28 | .addCase(fetchReleases.pending, (state) => { 29 | state.isLoadingReleases = true; 30 | }) 31 | .addCase(fetchReleases.fulfilled, (state, action) => { 32 | state.releases = action.payload; 33 | state.isLoadingReleases = false; 34 | state.isErrorLoadingReleases = false; 35 | }) 36 | .addCase(fetchReleases.rejected, (state) => { 37 | state.isLoadingReleases = false; 38 | state.isErrorLoadingReleases = true; 39 | }) 40 | .addCase(dismissSelectedPluginVersions.pending, (state) => { 41 | state.isUpdatingDismissedVersions = true; 42 | }) 43 | .addCase(dismissSelectedPluginVersions.fulfilled, (state) => { 44 | state.isUpdatingDismissedVersions = false; 45 | }) 46 | .addCase(dismissSelectedPluginVersions.rejected, (state) => { 47 | state.isUpdatingDismissedVersions = false; 48 | }) 49 | .addCase(unDismissPluginVersion.pending, (state) => { 50 | state.isUpdatingDismissedVersions = true; 51 | }) 52 | .addCase(unDismissPluginVersion.fulfilled, (state) => { 53 | state.isUpdatingDismissedVersions = false; 54 | }) 55 | .addCase(unDismissPluginVersion.rejected, (state) => { 56 | state.isUpdatingDismissedVersions = false; 57 | }) 58 | .addCase(cleanupDismissedPluginVersions.pending, (state) => { 59 | state.isUpdatingDismissedVersions = true; 60 | }) 61 | .addCase(cleanupDismissedPluginVersions.fulfilled, (state) => { 62 | state.isUpdatingDismissedVersions = false; 63 | }) 64 | .addCase(cleanupDismissedPluginVersions.rejected, (state) => { 65 | state.isUpdatingDismissedVersions = false; 66 | }); 67 | }, 68 | }); 69 | 70 | export default releaseReducer.reducer; 71 | -------------------------------------------------------------------------------- /src/state/selectors/countSelectedPlugins.ts: -------------------------------------------------------------------------------- 1 | import { State } from '..'; 2 | 3 | export const countSelectedPlugins = (state: State) => { 4 | return Object.values(state.obsidian.selectedPluginsById).reduce( 5 | (count, isSelected) => count + (isSelected ? 1 : 0), 6 | 0 7 | ); 8 | }; 9 | -------------------------------------------------------------------------------- /src/state/selectors/getSelectedPluginIds.ts: -------------------------------------------------------------------------------- 1 | import { State } from '..'; 2 | 3 | export function getSelectedPluginIds(state: State): string[] { 4 | return Object.keys(state.obsidian.selectedPluginsById).filter( 5 | (pluginId) => state.obsidian.selectedPluginsById[pluginId] 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "jsx": "react", 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "strictNullChecks": true, 17 | "lib": [ 18 | "DOM", 19 | "ES5", 20 | "ES6", 21 | "ES7" 22 | ] 23 | }, 24 | "include": [ 25 | "src/**/*.ts", 26 | "src/**/*.tsx", 27 | "oput-common/**/*.ts" 28 | ], 29 | "exclude": ["src/stories"] 30 | } 31 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.1": "0.15.0", 3 | "1.0.0": "0.15.0", 4 | "1.0.1": "0.15.0", 5 | "1.1.0": "0.15.0", 6 | "1.1.1": "0.15.0", 7 | "1.2.0": "0.15.0", 8 | "1.2.1": "0.15.0", 9 | "1.3.0": "0.15.0", 10 | "1.3.1": "0.15.0", 11 | "1.4.0": "0.15.0", 12 | "1.4.1": "0.15.0", 13 | "1.4.2": "0.15.0", 14 | "1.4.3": "0.15.0", 15 | "1.4.4": "0.15.0", 16 | "1.4.5": "0.15.0", 17 | "1.4.6": "0.15.0", 18 | "1.5.0": "0.15.0", 19 | "1.5.1": "0.15.0", 20 | "1.5.2": "0.15.0", 21 | "1.6.0": "0.15.0", 22 | "1.6.1": "0.15.0", 23 | "1.6.2": "0.15.0", 24 | "1.7.0": "0.15.0" 25 | } 26 | --------------------------------------------------------------------------------