├── .commitlintrc.json ├── .editorconfig ├── .gitattributes ├── .github ├── actions │ └── setup │ │ └── action.yaml ├── dependabot.yml └── workflows │ └── validate.yaml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── LICENSE ├── docs ├── bookmarksync-icon.svg ├── screenshot-options.png └── screenshot-popup.png ├── package.json ├── pnpm-lock.yaml ├── readme.md ├── src ├── assets │ └── bookmarksync-icon.svg ├── entrypoints │ ├── background.js │ ├── options │ │ ├── index.html │ │ ├── options.css │ │ └── options.js │ └── popup │ │ ├── index.html │ │ ├── popup.css │ │ └── popup.js ├── public │ └── icon │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 48.png │ │ ├── 64.png │ │ └── 96.png └── utils │ ├── bookmarks.1-0-0.schema.json │ ├── bookmarksync.js │ ├── errors.js │ ├── github-bookmarks-loader.js │ └── options-storage.js ├── tsconfig.json └── wxt.config.ts /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/actions/setup/action.yaml: -------------------------------------------------------------------------------- 1 | name: Basic Setup 2 | description: Install PNPM, Node, and dependencies 3 | runs: 4 | using: composite 5 | steps: 6 | - name: Setup PNPM 7 | uses: pnpm/action-setup@v2 8 | with: 9 | version: 8 10 | - name: Setup NodeJS 11 | uses: actions/setup-node@v4 12 | with: 13 | node-version: 18 14 | cache: pnpm 15 | - name: Install Dependencies 16 | shell: bash 17 | run: pnpm install 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: npm 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | open-pull-requests-limit: 12 13 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | on: 3 | workflow_call: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | branches: [ main ] 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | validate: 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: ./.github/actions/setup 17 | - run: pnpm lint 18 | 19 | build: 20 | runs-on: ubuntu-22.04 21 | strategy: 22 | matrix: 23 | browser: 24 | - firefox 25 | - chrome 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: ./.github/actions/setup 29 | - run: pnpm install 30 | - run: pnpm run build:${{ matrix.browser }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .output 12 | stats.html 13 | .wxt 14 | web-ext.config.ts 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit "$1" 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm run lint 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Frederik Bülthoff 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. -------------------------------------------------------------------------------- /docs/bookmarksync-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 20 | 23 | 27 | 31 | 32 | 35 | 39 | 43 | 44 | 47 | 51 | 55 | 56 | 59 | 63 | 67 | 71 | 72 | 75 | 79 | 83 | 84 | 87 | 91 | 95 | 96 | 105 | 114 | 123 | 132 | 141 | 150 | 151 | 169 | 174 | 177 | 186 | 195 | 204 | 213 | 218 | 223 | 228 | 229 | 230 | 234 | 237 | 240 | 241 | -------------------------------------------------------------------------------- /docs/screenshot-options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frederikb/bookmarksync/e68ec561c16ed02e872a90a4f1f274e61a3e917c/docs/screenshot-options.png -------------------------------------------------------------------------------- /docs/screenshot-popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frederikb/bookmarksync/e68ec561c16ed02e872a90a4f1f274e61a3e917c/docs/screenshot-popup.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bookmarksync", 3 | "description": "Synchronize browser bookmarks from a GitHub repository", 4 | "private": true, 5 | "version": "0.10.0", 6 | "license": "MIT", 7 | "type": "module", 8 | "scripts": { 9 | "dev": "wxt", 10 | "dev:firefox": "wxt -b firefox", 11 | "build": "wxt build", 12 | "build:firefox": "wxt build -b firefox", 13 | "build:chrome": "wxt build -b chrome", 14 | "zip": "wxt zip", 15 | "zip:firefox": "wxt zip -b firefox", 16 | "compile": "tsc --noEmit", 17 | "lint": "concurrently \"npm:lint:*\"", 18 | "lint-fix": "concurrently \"npm:lint:* -- --fix\"", 19 | "lint:css": "stylelint src/**/*.css", 20 | "lint:js": "xo", 21 | "postinstall": "wxt prepare", 22 | "prepare": "husky" 23 | }, 24 | "config": { 25 | "commitizen": { 26 | "path": "cz-conventional-changelog" 27 | } 28 | }, 29 | "xo": { 30 | "envs": [ 31 | "browser", 32 | "webextensions" 33 | ], 34 | "rules": { 35 | "unicorn/prefer-top-level-await": "off", 36 | "n/file-extension-in-import": "off" 37 | } 38 | }, 39 | "stylelint": { 40 | "rules": { 41 | "function-whitespace-after": null, 42 | "media-feature-range-operator-space-after": null, 43 | "media-feature-range-operator-space-before": null 44 | } 45 | }, 46 | "devDependencies": { 47 | "@commitlint/cli": "19.2.2", 48 | "@commitlint/config-conventional": "19.2.2", 49 | "concurrently": "^8.2.2", 50 | "cz-conventional-changelog": "3.3.0", 51 | "husky": "^9.0.11", 52 | "stylelint": "^15.11.0", 53 | "typescript": "^5.4.5", 54 | "wxt": "^0.17.12", 55 | "xo": "^0.58.0" 56 | }, 57 | "dependencies": { 58 | "@hyperjump/json-schema": "^1.8.0", 59 | "@hyperjump/browser": "^1.1.3", 60 | "@octokit/plugin-retry": "^7.1.0", 61 | "@octokit/rest": "^20.1.0", 62 | "@webext-core/job-scheduler": "^1.0.0", 63 | "@webext-core/proxy-service": "^1.2.0", 64 | "webext-base-css": "^1.4.4", 65 | "webext-options-sync": "^4.2.1" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 | Bookmarksync Logo 3 | Bookmark Sync for GitHub 4 |

5 | 6 | > Synchronize browser bookmarks from a GitHub repository. 7 | 8 | ## 🚀 **Why Use This Extension?** 9 | 10 | Have you ever wished to share a common set of bookmarks across a team or organization without needing everyone to manually update their bookmarks? 11 | 12 | This extension allows for just that. 13 | 14 | Store bookmarks in a simple JSON structure in your organization's GitHub repository, and let everyone have the latest bookmarks at their fingertips. 15 | 16 | ## 🚀 Features 17 | 18 | - ⏱️ **Automatic Synchronization**: Sync bookmarks every hour and shortly after the browser starts. 19 | - ✋ **Manual Sync**: Need the latest bookmarks immediately? Trigger a sync manually. 20 | - 🎯 **Selective Sync**: Syncs only the folders contained in the remote bookmark files without touching others you might have. 21 | - 🔄 **Multi-Source Synchronization**: Seamlessly integrate bookmarks from two separate sources, such as personal and work repositories. 22 | - 📁 **Multi-File Support**: Organize your organizations bookmarks into separate JSON files, for example by project. 23 | - 🔔 **Notifications**: Stay informed about successful syncs or if any issues arise. 24 | - 🔒 **Secure**: Uses GitHub's Personal Access Token (PAT) for authentication, ensuring secure access. 25 | - 🌐 **GitHub Enterprise Support**: Synchronize bookmarks from GitHub or GitHub Enterprise Server (GHES). 26 | 27 | ## 🛠 Installation 28 | 29 | [link-chrome]: https://chromewebstore.google.com/detail/bookmark-sync-for-github/fponkkcbgphbndjgodphgebonnfgikkl?pli=1 'Version published on Chrome Web Store' 30 | [link-firefox]: https://addons.mozilla.org/firefox/addon/bookmark-sync-for-github/ 'Version published on Mozilla Add-ons' 31 | 32 | [Firefox][link-firefox] [][link-firefox] 33 | 34 | [Chrome][link-chrome] [][link-chrome] also compatible with [Orion](https://kagi.com/orion/) 35 | 36 | ## 📖 Usage 37 | 38 | 1. **Install** the extension using the above steps. 39 | 2. **Configure** your GitHub Personal Access Token, the Git repository and the path to your bookmark JSON file(s). 40 | 3. The extension will **automatically synchronize** the bookmarks from the JSON file(s) into your bookmark bar. 41 | 42 | ## ⚙ Configuration 43 | 44 | Access the extension's options and provide: 45 | 46 | 1. **GitHub Personal Access Token**: Ensure this token has access to the repository. _Your token is stored securely and used only for fetching the files. Use a fine-grained token restricted to the repository._ 47 | 2. **Organization**: The account owner of the repository. The name is not case sensitive. 48 | 3. **Repository**: The name of the repository without the `.git` extension. The name is not case sensitive. 49 | 4. **Source Path**: The path within the repository to either a single JSON file or a directory containing multiple JSON bookmark files. For a single file, provide the path e.g., `path/to/bookmarks.json`. For a directory, just specify the folder path e.g., `bookmarks`. 50 | 51 | You can also synchronize bookmarks from a GitHub Enterprise Server (GHES) by specifying the **GitHub API URL** (which ends with `/api/v3`). 52 | 53 | 54 | Then make your bookmarks available at the source path in your repository to watch the magic happen. 55 | 56 | Use the `Check Connection` to test the configuration. 57 | 58 | ## 📄 Bookmark Collection JSON Format 59 | 60 | Structure your JSON file for bookmarks as per the schema defined at [https://frederikb.github.io/bookmarksync/schemas/bookmarks.1-0-0.schema.json](https://frederikb.github.io/bookmarksync/schemas/bookmarks.1-0-0.schema.json). 61 | 62 | ### Top-Level Structure 63 | 64 | | Field | Type | Required | Description | 65 | |-------------|--------|----------|-----------------------------------| 66 | | `$schema` | URI | No | Schema identifier. | 67 | | `name` | String | Yes | Name of the bookmark collection. | 68 | | `bookmarks` | Array | Yes | Array of bookmark items. | 69 | 70 | ### Bookmark Item Types 71 | 72 | #### Bookmark 73 | | Field | Type | Required | Description | 74 | |---------|--------|----------|-----------------------| 75 | | `title` | String | Yes | Title of the bookmark.| 76 | | `url` | URI | Yes | URL of the bookmark. | 77 | | `type` | String | No | Set to "bookmark". | 78 | 79 | #### Folder 80 | | Field | Type | Required | Description | 81 | |------------|--------|----------|-------------------------------------| 82 | | `title` | String | Yes | Title of the folder. | 83 | | `children` | Array | Yes | Array of nested bookmark items. | 84 | | `type` | String | No | Set to "folder". | 85 | 86 | #### Separator 87 | | Field | Type | Required | Description | 88 | |-------|--------|----------|--------------------| 89 | | `type`| String | Yes | Set to "separator".| 90 | 91 | ### Example 92 | 93 |
94 | Example Bookmark JSON (Click to expand) 95 | 96 | ```json 97 | { 98 | "$schema": "https://frederikb.github.io/bookmarksync/schemas/bookmarks.1-0-0.schema.json", 99 | "name": "Bookmarks 1", 100 | "bookmarks": [ 101 | { 102 | "title": "Work", 103 | "children": [ 104 | { 105 | "title": "Email", 106 | "url": "https://mail.example.com" 107 | }, 108 | { 109 | "title": "Docs", 110 | "children": [ 111 | { 112 | "title": "Specs", 113 | "url": "https://specs.example.com" 114 | }, 115 | { 116 | "type": "separator" 117 | }, 118 | { 119 | "title": "Reports", 120 | "url": "https://reports.example.com" 121 | } 122 | ] 123 | } 124 | ] 125 | } 126 | ] 127 | } 128 | ``` 129 |
130 | 131 | 132 | ## 📸 Screenshots 133 | 134 | ![Options Page](docs/screenshot-options.png) 135 |
136 | *Options Page* - Configure your GitHub Personal Access Token and repository details. 137 | 138 | ![Popup Screen](docs/screenshot-popup.png) 139 |
140 | *Popup Screen* - Manually trigger a sync. 141 | 142 | ## 🛑 Known Limitations 143 | 144 | - 🚧 **Manual Cleanup**: If a folder or bookmark that was added to the Bookmarks Bar via the sync is no longer present in any of the synced bookmark JSON files, it will not be automatically removed. Such entries need to be manually cleaned up by the user. 145 | 146 | ## 🤝 Contributing 147 | 148 | Contributions make the open source community an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 149 | 150 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the label "enhancement". 151 | 152 | ## 📜 License 153 | 154 | Distributed under the MIT License. See [`LICENSE`](LICENSE) for more information. 155 | 156 | ## 📣 Acknowledgements 157 | 158 | - [Octokit](https://github.com/octokit/core.js): Seamless GitHub API integration. 159 | - [Hyperjump - JSON Schema](https://github.com/hyperjump-io/json-schema): JSON Schema tooling. 160 | - This project was bootstrapped with [Web Extension Toolkit (wxt.dev)](https://wxt.dev). 161 | -------------------------------------------------------------------------------- /src/assets/bookmarksync-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 20 | 23 | 27 | 31 | 32 | 35 | 39 | 43 | 44 | 47 | 51 | 55 | 56 | 59 | 63 | 67 | 71 | 72 | 75 | 79 | 83 | 84 | 87 | 91 | 95 | 96 | 105 | 114 | 123 | 132 | 141 | 150 | 151 | 169 | 174 | 177 | 186 | 195 | 204 | 213 | 218 | 223 | 228 | 229 | 230 | 234 | 237 | 240 | 241 | -------------------------------------------------------------------------------- /src/entrypoints/background.js: -------------------------------------------------------------------------------- 1 | import {defineBackground} from 'wxt/sandbox'; 2 | import {browser} from 'wxt/browser'; 3 | import {defineJobScheduler} from '@webext-core/job-scheduler'; 4 | import GitHubBookmarksLoader from '@/utils/github-bookmarks-loader.js'; 5 | import {registerSyncBookmarks, getSyncBookmarks} from '@/utils/bookmarksync.js'; 6 | 7 | export default defineBackground({ 8 | persistent: false, 9 | type: 'module', 10 | 11 | main() { 12 | registerSyncBookmarks(new GitHubBookmarksLoader()); 13 | 14 | const bookmarkSyncService = getSyncBookmarks(); 15 | 16 | const jobs = defineJobScheduler(); 17 | 18 | console.log('💈 Background script loaded for', chrome.runtime.getManifest().name); 19 | 20 | browser.runtime.onInstalled.addListener(details => { 21 | console.log('Extension installed:', details); 22 | bookmarkSyncService.synchronizeBookmarks(); 23 | }); 24 | 25 | jobs.scheduleJob({ 26 | id: 'sync-bookmarks', 27 | type: 'interval', 28 | duration: 1000 * 3600, // Runs hourly 29 | execute() { 30 | console.log('Scheduled sync bookmarks job'); 31 | bookmarkSyncService.synchronizeBookmarks(); 32 | }, 33 | }); 34 | 35 | jobs.scheduleJob({ 36 | id: 'startup-sync-bookmarks', 37 | type: 'once', 38 | date: Date.now() + (1000 * 30), // 30 seconds after extension init (browser start) 39 | execute() { 40 | console.log('Syncing bookmarks on browser startup'); 41 | bookmarkSyncService.synchronizeBookmarks(); 42 | }, 43 | }); 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /src/entrypoints/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Options 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | 🔄 Bookmarks source 16 |
17 |
18 |
19 |
20 |

21 | 25 |

26 |

27 | 31 |

32 |

33 | 37 |

38 |

39 | 45 |

46 |

47 | 51 |

52 |
53 |
54 |
55 |

56 | 60 |

61 |
62 |
63 |

64 | 68 |

69 |

70 | 74 |

75 |

76 | 80 |

81 |

82 | 88 |

89 |

90 | 94 |

95 |
96 |
97 |
98 | 99 |
100 |

101 |
102 |
103 |
104 |
105 | 106 |
107 | 🗄️ Export options 108 |
109 |
110 |
111 |

112 | You can export and import options across browsers and devices via a JSON file. 113 |

114 |

115 | Note that your options include your access token! 116 |

117 |

118 | 119 | 120 |

121 |
122 |
123 |
124 | 125 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /src/entrypoints/options/options.css: -------------------------------------------------------------------------------- 1 | html { 2 | min-width: 550px; 3 | min-height: 25rem; 4 | overflow-x: hidden; 5 | --details-border-left: 3px; 6 | --details-color-default: #aaa1; 7 | --details-color-active: #aaa4; 8 | --details-animation-duration: 300ms; 9 | } 10 | 11 | output { 12 | font-style: italic; 13 | } 14 | 15 | details { 16 | overflow: hidden; 17 | } 18 | 19 | summary { 20 | display: block; 21 | --summary-padding: 10px; 22 | background: #aaa1; 23 | list-style: none; 24 | padding: var(--summary-padding); 25 | cursor: pointer; 26 | } 27 | 28 | summary::-webkit-details-marker { 29 | display: none; 30 | /* Just for Safari. Chrome uses `list-style` */ 31 | } 32 | 33 | summary > strong::before { 34 | display: inline-block; 35 | content: "►"; 36 | font-size: .8rem; 37 | vertical-align: middle; 38 | margin-right: 0.5rem; 39 | rotate: 0deg; 40 | transition: rotate 200ms 100ms ease-out; 41 | } 42 | 43 | details:hover > summary { 44 | background: #aaa4; 45 | } 46 | 47 | details:has(+ div.content:hover) > summary { 48 | background: #aaa4; 49 | } 50 | 51 | details[open] summary { 52 | padding-left: calc(var(--summary-padding) - var(--details-border-left)); 53 | } 54 | 55 | details[open] summary > strong::before { 56 | rotate: 90deg; 57 | transition: rotate 200ms 100ms ease-out; 58 | } 59 | 60 | div.content { 61 | margin-bottom: 1em; 62 | border-left: solid var(--details-border-left) var(--details-color-default); 63 | display: grid; 64 | grid-template-rows: 0fr; 65 | transition: grid-template-rows var(--details-animation-duration) ease-out; 66 | } 67 | 68 | details + div.content { 69 | padding-left: 10px; 70 | } 71 | 72 | details[open] + div.content { 73 | grid-template-rows: 1fr; 74 | } 75 | 76 | div.content > div { 77 | overflow: hidden; 78 | } 79 | 80 | details:hover, details + div.content:hover, details:hover + div.content, details:has(+ div.content:hover) { 81 | border-left-color: var(--details-color-active); 82 | } 83 | 84 | #options-form input[type="text"], 85 | #options-form input[type="password"], 86 | #options-form input[type="url"] { 87 | margin: 0; 88 | padding: 5px; 89 | border: 1px solid #ccc; 90 | border-radius: 4px; 91 | } 92 | 93 | #options-form input:invalid { 94 | border: 1px solid #f08080; 95 | } 96 | 97 | #options-form label.text-input { 98 | display: flex; 99 | justify-content: space-between; 100 | align-items: center; 101 | width: 100%; 102 | } 103 | 104 | label.text-input>span:first-child { 105 | flex: 0 0 auto; 106 | padding-right: 10px; 107 | width: 120px; 108 | } 109 | 110 | p.repo-source { 111 | display: flex; 112 | justify-content: space-between; 113 | align-items: center; 114 | margin: 0; 115 | } 116 | 117 | .repo-source input[type="text"] { 118 | flex-grow: 1; 119 | min-width: 0; 120 | } 121 | 122 | label.text-input>.divider { 123 | width: 20px; 124 | flex: 0 0 auto; 125 | text-align: center; 126 | } 127 | 128 | hr { 129 | margin-bottom: 15px; 130 | } 131 | 132 | .custom-host-container, #source2 { 133 | visibility: visible; 134 | transition: max-height 0.5s ease-in-out; 135 | max-height: 10rem; 136 | overflow: hidden; 137 | } 138 | 139 | .custom-host-container.hidden, #source2.hidden { 140 | visibility: hidden; 141 | max-height: 0; 142 | transition: max-height 0.5s ease-in-out, visibility 0s 0.5s; 143 | } 144 | 145 | #source2 { 146 | visibility: visible; 147 | transition: max-height 0.5s ease-in-out; 148 | max-height: 15rem; 149 | overflow: hidden; 150 | } 151 | 152 | #source2.hidden { 153 | visibility: hidden; 154 | max-height: 0; 155 | transition: max-height 0.5s ease-in-out, visibility 0s 0.5s; 156 | } 157 | 158 | .check-connection-btn { 159 | width: 11rem; 160 | cursor: pointer; 161 | display: inline-block; 162 | } 163 | 164 | .check-connection-btn:disabled { 165 | cursor: not-allowed; 166 | } 167 | 168 | .check-connection-btn.in-progress { 169 | cursor: progress; 170 | } 171 | 172 | .connection-message { 173 | border-radius: 4px; 174 | padding: 10px 15px; 175 | margin: 10px 0; 176 | display: block; 177 | box-sizing: border-box; 178 | } 179 | 180 | .connection-message.hidden { 181 | display: none; 182 | } 183 | 184 | .connection-message.success { 185 | color: #003300; 186 | background-color: #ccffcc; 187 | border: 1px solid #004400; 188 | } 189 | 190 | .connection-message.error { 191 | color: #330000; 192 | background-color: #ffcccc; 193 | border: 1px solid #440000; 194 | } 195 | 196 | /* Firefox specific styling overrides */ 197 | @-moz-document url-prefix('') { 198 | input[type='checkbox'] { 199 | /* override webext-base-css style to better align checkboxes with single line labels */ 200 | vertical-align: -0.1em; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/entrypoints/options/options.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unassigned-import 2 | import 'webext-base-css'; 3 | import './options.css'; 4 | import optionsStorage from '@/utils/options-storage.js'; 5 | import {getSyncBookmarks} from '@/utils/bookmarksync.js'; 6 | 7 | const bookmarkSyncService = getSyncBookmarks(); 8 | 9 | /** 10 | * @typedef {'success' | 'error' | 'in-progress' | null} CheckStatus 11 | */ 12 | 13 | async function init() { 14 | const form = document.querySelector('#options-form'); 15 | 16 | const checkConnectionButton = document.querySelector('.check-connection-btn'); 17 | const connectionMessage = document.querySelector('.connection-message'); 18 | 19 | let isChecking = false; 20 | let cancelCurrentCheck = false; 21 | 22 | /** 23 | * Displays a connection message. 24 | * @param {CheckStatus} status - The status of the connection check. 25 | * @param {string} message - The message to display. 26 | */ 27 | const showConnectionMessage = (status, message) => { 28 | connectionMessage.textContent = message; 29 | connectionMessage.className = ''; 30 | connectionMessage.classList.add('connection-message', status); 31 | }; 32 | 33 | /** 34 | * Clear the connection message display. 35 | */ 36 | const clearConnectionMessage = () => { 37 | connectionMessage.textContent = ''; 38 | connectionMessage.className = ''; 39 | connectionMessage.classList.add('connection-message', 'hidden'); 40 | }; 41 | 42 | /** 43 | * Updates the text and appearance of the check connection button given a status. 44 | * @param {CheckStatus} status - The status of the connection check. 45 | */ 46 | const updateButton = status => { 47 | checkConnectionButton.textContent = status === 'in-progress' ? 'Checking…' : 'Check Connection(s)'; 48 | checkConnectionButton.disabled = status === 'in-progress'; 49 | }; 50 | 51 | checkConnectionButton.addEventListener('click', async () => { 52 | if (isChecking) { 53 | return; 54 | } 55 | 56 | isChecking = true; 57 | cancelCurrentCheck = false; 58 | clearConnectionMessage(); 59 | updateButton('in-progress'); 60 | 61 | try { 62 | await bookmarkSyncService.validateBookmarks(); 63 | if (cancelCurrentCheck) { 64 | updateButton(null); 65 | return; 66 | } 67 | 68 | updateButton('success'); 69 | showConnectionMessage('success', 'Success'); 70 | } catch (error) { 71 | if (cancelCurrentCheck) { 72 | updateButton(null); 73 | return; 74 | } 75 | 76 | updateButton('error'); 77 | showConnectionMessage('error', error.message); 78 | } finally { 79 | isChecking = false; 80 | cancelCurrentCheck = false; 81 | } 82 | }); 83 | 84 | const resetCheckConnection = () => { 85 | if (!isChecking) { 86 | updateButton(null); 87 | clearConnectionMessage(); 88 | } 89 | }; 90 | 91 | async function setupConnectionSource(form, sourceId) { 92 | const section = document.querySelector(`div#${sourceId}`); 93 | 94 | const showCustomHostInput = show => { 95 | const customHostContainer = section.querySelector('.custom-host-container'); 96 | if (show) { 97 | customHostContainer.classList.remove('hidden'); 98 | } else { 99 | customHostContainer.classList.add('hidden'); 100 | } 101 | 102 | customHostContainer.querySelector('input').required = show; 103 | }; 104 | 105 | const clearCustomHostInput = () => { 106 | section.querySelector(`[name='${sourceId}_githubApiUrl']`).value = ''; 107 | }; 108 | 109 | const setupCustomHostInput = async () => { 110 | const useCustomHostPropertyName = `${sourceId}_useCustomHost`; 111 | const githubApiUrlPropertyName = `${sourceId}_githubApiUrl`; 112 | 113 | const {[useCustomHostPropertyName]: useCustomHost = false} = await optionsStorage.getAll(); 114 | showCustomHostInput(useCustomHost); 115 | if (!useCustomHost) { 116 | await optionsStorage.set({[githubApiUrlPropertyName]: ''}); 117 | clearCustomHostInput(); 118 | } 119 | }; 120 | 121 | function setupActivateAdditionalBookmarkSource() { 122 | return async function () { 123 | const activePropertyName = `${sourceId}_active`; 124 | const {[activePropertyName]: active = true} = await optionsStorage.getAll(); 125 | for (const input of section.querySelectorAll('input:not([type="checkbox"])')) { 126 | input.required = active; 127 | } 128 | 129 | if (active) { 130 | section.classList.remove('hidden'); 131 | } else { 132 | section.classList.add('hidden'); 133 | } 134 | }; 135 | } 136 | 137 | const syncActivateAdditionalBookmarkSource = setupActivateAdditionalBookmarkSource(); 138 | 139 | form.addEventListener('options-sync:form-synced', async () => { 140 | await setupState(); 141 | }); 142 | 143 | async function setupState() { 144 | await syncActivateAdditionalBookmarkSource(); 145 | await setupCustomHostInput(); 146 | cancelCurrentCheck = true; 147 | resetCheckConnection(); 148 | } 149 | 150 | await setupState(); 151 | } 152 | 153 | await setupConnectionSource(form, 'source1'); 154 | await setupConnectionSource(form, 'source2'); 155 | 156 | await optionsStorage.syncForm(form); 157 | } 158 | 159 | init(); 160 | -------------------------------------------------------------------------------- /src/entrypoints/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Bookmark Sync for GitHub 8 | 9 | 10 | 11 | 12 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/entrypoints/popup/popup.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: normal; 5 | color-scheme: light dark; 6 | color: rgb(255 255 255 / 87%); 7 | background-color: #242424; 8 | font-synthesis: none; 9 | text-rendering: optimizelegibility; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | /* stylelint-disable-next-line property-no-vendor-prefix */ 13 | -webkit-text-size-adjust: 100%; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #0a698fe0; 19 | text-decoration: inherit; 20 | } 21 | 22 | a:hover { 23 | color: #096184e0; 24 | } 25 | 26 | body { 27 | margin: 0; 28 | display: flex; 29 | place-items: center; 30 | min-width: 220px; 31 | min-height: 80vh; 32 | } 33 | 34 | h1 { 35 | font-size: 3.2em; 36 | line-height: 1.1; 37 | } 38 | 39 | #popup { 40 | max-width: 1280px; 41 | margin: 0 auto; 42 | padding: 1em 0 0; 43 | text-align: center; 44 | } 45 | 46 | .logo { 47 | height: 6em; 48 | padding: 1em; 49 | will-change: filter; 50 | transition: filter 300ms; 51 | } 52 | 53 | .logo:hover { 54 | filter: drop-shadow(0 0 2em #0a698fe0); 55 | } 56 | 57 | .card { 58 | padding: 2em; 59 | } 60 | 61 | .text { 62 | color: #888; 63 | } 64 | 65 | button { 66 | border-radius: 8px; 67 | border: 1px solid transparent; 68 | padding: 0.6em 1.2em; 69 | font-size: 1em; 70 | font-weight: 500; 71 | font-family: inherit; 72 | background-color: #1a1a1a; 73 | cursor: pointer; 74 | transition: border-color 0.25s; 75 | } 76 | 77 | button:hover { 78 | border-color: #0a698fe0; 79 | } 80 | 81 | button:focus, 82 | button:focus-visible { 83 | outline: 4px auto -webkit-focus-ring-color; 84 | } 85 | 86 | button:disabled { 87 | background-color: #757575; 88 | color: #ccc; 89 | cursor: not-allowed; 90 | border: 1px solid #606060; 91 | opacity: 0.7; 92 | } 93 | @media (prefers-color-scheme: light) { 94 | :root { 95 | color: #213547; 96 | background-color: #fff; 97 | } 98 | 99 | a:hover { 100 | color: #747bff; 101 | } 102 | 103 | button { 104 | background-color: #f9f9f9; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/entrypoints/popup/popup.js: -------------------------------------------------------------------------------- 1 | import './popup.css'; 2 | import logo from '@/assets/bookmarksync-icon.svg'; 3 | import {getSyncBookmarks} from '@/utils/bookmarksync.js'; 4 | 5 | const bookmarkSyncService = getSyncBookmarks(); 6 | 7 | document.querySelector('#bookmarksync-logo').src = logo; 8 | 9 | const syncButton = document.querySelector('#sync-now'); 10 | syncButton.addEventListener('click', async () => { 11 | console.log('Manual sync triggered'); 12 | const originalText = syncButton.textContent; 13 | syncButton.textContent = 'Synchronizing...'; 14 | syncButton.disabled = true; 15 | try { 16 | await bookmarkSyncService.synchronizeBookmarks(true); 17 | } catch (error) { 18 | console.error('Error triggering manual bookmark sync:', error); 19 | } finally { 20 | syncButton.disabled = false; 21 | syncButton.textContent = originalText; 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /src/public/icon/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frederikb/bookmarksync/e68ec561c16ed02e872a90a4f1f274e61a3e917c/src/public/icon/128.png -------------------------------------------------------------------------------- /src/public/icon/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frederikb/bookmarksync/e68ec561c16ed02e872a90a4f1f274e61a3e917c/src/public/icon/16.png -------------------------------------------------------------------------------- /src/public/icon/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frederikb/bookmarksync/e68ec561c16ed02e872a90a4f1f274e61a3e917c/src/public/icon/48.png -------------------------------------------------------------------------------- /src/public/icon/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frederikb/bookmarksync/e68ec561c16ed02e872a90a4f1f274e61a3e917c/src/public/icon/64.png -------------------------------------------------------------------------------- /src/public/icon/96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frederikb/bookmarksync/e68ec561c16ed02e872a90a4f1f274e61a3e917c/src/public/icon/96.png -------------------------------------------------------------------------------- /src/utils/bookmarks.1-0-0.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "https://frederikb.github.io/bookmarksync/schemas/bookmarks.1-0-0.schema.json", 4 | "title": "Bookmarks", 5 | "description": "A named collection of bookmarks as used in a web browser", 6 | "type": "object", 7 | "additionalProperties": false, 8 | "properties": { 9 | "$schema": { 10 | "type": "string", 11 | "format": "uri", 12 | "enum": [ 13 | "https://frederikb.github.io/bookmarksync/schemas/bookmarks.1-0-0.schema.json" 14 | ], 15 | "description": "The URI of this exact JSON schema" 16 | }, 17 | "name": { 18 | "type": "string", 19 | "description": "Name of the bookmark collection." 20 | }, 21 | "bookmarks": { 22 | "type": "array", 23 | "items": { 24 | "$ref": "#/definitions/bookmarkItem" 25 | }, 26 | "description": "Array of bookmarks, separators or folders." 27 | } 28 | }, 29 | "required": [ 30 | "name", 31 | "bookmarks" 32 | ], 33 | "definitions": { 34 | "bookmarkItem": { 35 | "type": "object", 36 | "properties": { 37 | "title": { 38 | "type": "string", 39 | "description": "Title of the bookmark or folder. Required unless 'type' is 'separator'." 40 | }, 41 | "url": { 42 | "type": "string", 43 | "format": "uri", 44 | "description": "URL of the bookmark. Only for bookmarks." 45 | }, 46 | "children": { 47 | "type": "array", 48 | "items": { 49 | "$ref": "#/definitions/bookmarkItem" 50 | }, 51 | "description": "Nested bookmarks or folders. Only for folders." 52 | }, 53 | "type": { 54 | "type": "string", 55 | "enum": [ 56 | "folder", 57 | "bookmark", 58 | "separator" 59 | ], 60 | "description": "Type of the item. If absent, inferred from properties." 61 | } 62 | }, 63 | "allOf": [ 64 | { 65 | "if": { 66 | "required": [ 67 | "type" 68 | ], 69 | "properties": { 70 | "type": { 71 | "const": "separator" 72 | } 73 | } 74 | }, 75 | "then": { 76 | "properties": { 77 | "title": false, 78 | "url": false, 79 | "children": false 80 | } 81 | } 82 | }, 83 | { 84 | "if": { 85 | "required": [ 86 | "type" 87 | ], 88 | "properties": { 89 | "type": { 90 | "const": "bookmark" 91 | } 92 | } 93 | }, 94 | "then": { 95 | "required": [ 96 | "url", 97 | "title" 98 | ], 99 | "properties": { 100 | "children": false 101 | } 102 | } 103 | }, 104 | { 105 | "if": { 106 | "required": [ 107 | "type" 108 | ], 109 | "properties": { 110 | "type": { 111 | "const": "folder" 112 | } 113 | } 114 | }, 115 | "then": { 116 | "required": [ 117 | "children" 118 | ], 119 | "properties": { 120 | "url": false 121 | } 122 | } 123 | }, 124 | { 125 | "if": { 126 | "properties": { 127 | "type": false 128 | } 129 | }, 130 | "then": { 131 | "required": [ 132 | "title" 133 | ], 134 | "oneOf": [ 135 | { 136 | "required": [ 137 | "url" 138 | ], 139 | "properties": { 140 | "children": false 141 | } 142 | }, 143 | { 144 | "required": [ 145 | "children" 146 | ], 147 | "properties": { 148 | "url": false 149 | } 150 | } 151 | ] 152 | } 153 | } 154 | ] 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/utils/bookmarksync.js: -------------------------------------------------------------------------------- 1 | import {browser} from 'wxt/browser'; 2 | import {defineProxyService} from '@webext-core/proxy-service'; 3 | import {registerSchema, validate} from '@hyperjump/json-schema/draft-07'; 4 | import { 5 | BookmarkSourceNotConfiguredError, AuthenticationError, BookmarksDataNotValidError, DataNotFoundError, RepositoryNotFoundError, 6 | } from './errors.js'; 7 | import bookmarkSchema from '@/utils/bookmarks.1-0-0.schema.json'; 8 | 9 | /** 10 | * @typedef {Object} BookmarkCollection 11 | * @property {string} $schema - The URI of this exact JSON schema. Only allowed value is 'https://frederikb.github.io/bookmarksync/schemas/bookmarks.1-0-0.schema.json'. 12 | * @property {string} name - Name of the bookmark collection. 13 | * @property {BookmarkItem[]} bookmarks - Array of bookmarks, separators or folders. 14 | */ 15 | 16 | /** 17 | * @typedef {Object} BookmarkItem 18 | * @property {string} [title] - Title of the bookmark or folder. Required unless 'type' is 'separator'. 19 | * @property {string} [url] - URL of the bookmark. Only for bookmarks. 20 | * @property {BookmarkItem[]} [children] - Nested bookmarks or folders. Only for folders. 21 | * @property {'folder' | 'bookmark' | 'separator'} [type] - Type of the item. If absent, inferred from properties. 22 | */ 23 | 24 | /** 25 | * Interface for a service capable of loading bookmark data. 26 | * @typedef {Object} BookmarkLoader 27 | * @property {function({force?: boolean, cacheEtag?: boolean}): Promise} load Loads bookmark data, optionally using cache control. 28 | */ 29 | 30 | registerSchema(bookmarkSchema); 31 | 32 | /** 33 | * Service for synchronizing bookmarks to the browser's bookmark bar. 34 | */ 35 | class BookmarkSyncService { 36 | /** 37 | * The loader for fetching bookmark data 38 | * @type {BookmarkLoader} 39 | */ 40 | #loader; 41 | 42 | /** 43 | * @param {BookmarkLoader} loader the loader for fetching bookmark data 44 | */ 45 | constructor(loader) { 46 | this.#loader = loader; 47 | } 48 | 49 | /** 50 | * Synchronizes bookmarks retrieved via the configured loader to the browser. 51 | * 52 | * @param {boolean} [force=false] whether to force re-fetching and synchronization of bookmarks 53 | * @returns {Promise} a promise that resolves when synchronization is complete 54 | */ 55 | async synchronizeBookmarks(force = false) { 56 | try { 57 | console.log(`Starting ${force ? 'forced ' : ''}bookmark synchronization`); 58 | 59 | const bookmarkFiles = await this.#loader.load({force}); 60 | 61 | if (bookmarkFiles) { 62 | await validateBookmarkFiles(bookmarkFiles); 63 | const bookmarks = bookmarkFiles.flatMap(file => file.bookmarks); 64 | const deduplicatedBookmarks = removeDuplicatesByTitle(bookmarks); 65 | await syncBookmarksToBrowser(deduplicatedBookmarks); 66 | await notify('Bookmarks synchronized', 'Your bookmarks have been updated.'); 67 | console.log('Bookmarks synchronized'); 68 | } 69 | } catch (error) { 70 | if (error instanceof BookmarkSourceNotConfiguredError) { 71 | // Do not error out - perhaps the user just didn't yet set it up 72 | console.log('Could not sync because the required configuration values are not set'); 73 | } else if (error instanceof AuthenticationError) { 74 | console.error('Authentication error:', error.message, error.originalError); 75 | await notify('Authentication failed', error.message); 76 | } else if (error instanceof DataNotFoundError) { 77 | console.error('Data not found error:', error.message, error.originalError); 78 | await notify('Data not found', error.message); 79 | } else if (error instanceof BookmarksDataNotValidError) { 80 | console.error('Bookmark data not valid error:', error.message); 81 | await notify('Invalid bookmark data', `Canceling synchronization: ${error.message}`); 82 | } else if (error instanceof RepositoryNotFoundError) { 83 | console.error('Repository not found error:', error.message); 84 | await notify('Repo not found', error.message); 85 | } else { 86 | console.error('Error during synchronization:', error); 87 | await notify('Synchronization failed', 'Failed to update bookmarks.'); 88 | } 89 | } 90 | } 91 | 92 | /** 93 | * Validates that valid bookmarks can be retrieved from the configured source. 94 | * 95 | * Throws exceptions in case of any problems. 96 | * 97 | * @returns {Promise} a promise that resolves when validation is complete 98 | */ 99 | async validateBookmarks() { 100 | console.log('Validating the configured source of bookmarks'); 101 | 102 | const bookmarkFiles = await this.#loader.load({force: true, cacheEtag: false}); 103 | await validateBookmarkFiles(bookmarkFiles); 104 | } 105 | } 106 | 107 | export const [registerSyncBookmarks, getSyncBookmarks] = defineProxyService( 108 | 'SyncBookmarksService', 109 | loader => new BookmarkSyncService(loader), 110 | ); 111 | 112 | /** 113 | * Validates bookmark files against the bookmarks JSON schema. 114 | * 115 | * @param {BookmarkCollection[]} bookmarkFiles an array of bookmark files to validate 116 | * @throws {BookmarksDataNotValidError} thrown if any bookmark file fails to meet the schema requirements 117 | * @returns {Promise} a promise that resolves when all bookmark files have been validated 118 | */ 119 | async function validateBookmarkFiles(bookmarkFiles) { 120 | const BOOKMARK_SCHEMA_URI = 'https://frederikb.github.io/bookmarksync/schemas/bookmarks.1-0-0.schema.json'; 121 | const validator = await validate(BOOKMARK_SCHEMA_URI); 122 | 123 | for (const bookmarkFile of bookmarkFiles) { 124 | const validationResult = validator(bookmarkFile); 125 | if (!validationResult.valid) { 126 | const name = bookmarkFile.name || ''; 127 | throw new BookmarksDataNotValidError(`The bookmarks file with name '${name}' is not valid`); 128 | } 129 | } 130 | } 131 | 132 | /** 133 | * Remove duplicate bookmark nodes based on their title. 134 | * 135 | * @param {BookmarkItem[]} bookmarks the bookmarks 136 | * @returns {BookmarkItem[]} the deduplicated bookmarks 137 | */ 138 | function removeDuplicatesByTitle(bookmarks) { 139 | return Array.from(new Map(bookmarks.map(item => [item.title, item])).values()); 140 | } 141 | 142 | /** 143 | * Synchronize bookmarks to the browser's bookmarks bar. 144 | * 145 | * Replaces any existing bookmarks or folders with the same title. 146 | * 147 | * @param {BookmarkItem[]} newBookmarks the bookmarks 148 | */ 149 | async function syncBookmarksToBrowser(newBookmarks) { 150 | const bookmarksBarId = findBookmarksBarId(); 151 | if (!bookmarksBarId) { 152 | throw new Error('Bookmarks Bar not found'); 153 | } 154 | 155 | const existingBookmarksAndFolders = await getExisting(bookmarksBarId); 156 | 157 | for (const newBookmarkItem of newBookmarks) { 158 | // eslint-disable-next-line no-await-in-loop 159 | await syncBookmarksRootNode(bookmarksBarId, newBookmarkItem, existingBookmarksAndFolders); 160 | } 161 | } 162 | 163 | /** 164 | * Fetches the bookmarks bar ID based on the browser for which this extension is compiled. 165 | * 166 | * @returns {string} the bookmarks bar ID 167 | */ 168 | function findBookmarksBarId() { 169 | if (import.meta.env.FIREFOX) { 170 | return 'toolbar_____'; 171 | } 172 | 173 | // Fallback to the id '1' which works at least in Chrome and Orion 174 | return '1'; 175 | } 176 | 177 | /** 178 | * Retrieves the existing bookmarks under a given bookmarks bar by ID and maps them by title. 179 | * 180 | * @param {string} bookmarksBarId the ID of the bookmarks bar to retrieve children from 181 | * @returns {Promise>} a map of bookmark titles to their corresponding bookmark items 182 | */ 183 | async function getExisting(bookmarksBarId) { 184 | const children = await browser.bookmarks.getChildren(bookmarksBarId); 185 | // eslint-disable-next-line unicorn/no-array-reduce 186 | return children.reduce((accumulator, item) => accumulator.set(item.title, item), new Map()); 187 | } 188 | 189 | /** 190 | * Synchronize a bookmark item to the bookmark bar. 191 | * 192 | * If an existing bookmark or folder with the same title is found directly on the bookmark bar it will be replaced. 193 | * If the bookmark item to sync is a folder, it recursively creates its children. 194 | * 195 | * @param {string} bookmarkBarId the ID of the bookmarks bar where the bookmark or folder will be created 196 | * @param {BookmarkItem} newBookmarkItem the bookmark item to sync 197 | * @param {Map} existingBookmarksAndFolders a map of existing bookmark nodes and folders, keyed by title 198 | */ 199 | async function syncBookmarksRootNode(bookmarkBarId, newBookmarkItem, existingBookmarksAndFolders) { 200 | const existingNode = existingBookmarksAndFolders.get(newBookmarkItem.title); 201 | 202 | if (existingNode) { 203 | await (existingNode.url ? browser.bookmarks.remove(existingNode.id) : browser.bookmarks.removeTree(existingNode.id)); 204 | } 205 | 206 | const newNode = await browser.bookmarks.create({ 207 | parentId: bookmarkBarId, 208 | title: newBookmarkItem.title, 209 | url: newBookmarkItem.url, 210 | }); 211 | 212 | if (!newBookmarkItem.url && newBookmarkItem.children) { 213 | await createBookmarks(newNode.id, newBookmarkItem.children); 214 | } 215 | } 216 | 217 | /** 218 | * Creates bookmarks recursively under a specified parent node. 219 | * 220 | * It considers environmental differences, such as feature availability in different browsers. 221 | * 222 | * @param {string} parentId the ID of the parent node where the bookmarks will be created 223 | * @param {BookmarkItem[]} bookmarks an array of bookmark items to create, which may include folders and separators 224 | * @returns {Promise} a promise that resolves when all bookmarks have been created 225 | */ 226 | async function createBookmarks(parentId, bookmarks) { 227 | /* eslint-disable no-await-in-loop */ 228 | for (const item of bookmarks) { 229 | if (item.type === 'folder' || item.children) { 230 | const newFolder = await browser.bookmarks.create({parentId, title: item.title}); 231 | await createBookmarks(newFolder.id, item.children); 232 | } else if (item.type === 'separator') { 233 | if (import.meta.env.FIREFOX) { 234 | await createSeparator(parentId); 235 | } 236 | } else { 237 | await browser.bookmarks.create({parentId, title: item.title, url: item.url}); 238 | } 239 | } 240 | /* eslint-enable no-await-in-loop */ 241 | } 242 | 243 | /** 244 | * Creates a bookmark separator under a specified parent node. 245 | * 246 | * This functionality is typically browser-specific and might not be supported in all browsers. 247 | * The caller is responsible for ensuring that this function is only called for supported browsers. 248 | * 249 | * @param {string} parentId the ID of the parent node under which to create the separator 250 | * @returns {Promise} a promise that resolves to the newly created bookmark separator node 251 | */ 252 | async function createSeparator(parentId) { 253 | return browser.bookmarks.create({parentId, type: 'separator'}); 254 | } 255 | 256 | /** 257 | * Send a branded browser notification. 258 | * 259 | * Wrapper around the browser.notifications API. 260 | * 261 | * @param {string} title the title of the notification 262 | * @param {string} message the message content of the notification 263 | * @param {string} [type='basic'] the type of notification to create 264 | * @returns {Promise} a promise that resolves to the ID of the created notification 265 | */ 266 | async function notify(title, message, type = 'basic') { 267 | const id = `sync-bookmarks-notification-${Date.now()}`; 268 | return browser.notifications.create(id, { 269 | type, 270 | iconUrl: browser.runtime.getURL('/icon/128.png'), 271 | title, 272 | message, 273 | }); 274 | } 275 | -------------------------------------------------------------------------------- /src/utils/errors.js: -------------------------------------------------------------------------------- 1 | class AuthenticationError extends Error { 2 | constructor(message, originalError) { 3 | super(message); 4 | this.name = 'AuthenticationError'; 5 | this.originalError = originalError; 6 | } 7 | } 8 | 9 | class BookmarkSourceNotConfiguredError extends Error { 10 | constructor(message) { 11 | super(message); 12 | this.name = 'BookmarkSourceNotConfiguredError'; 13 | } 14 | } 15 | 16 | class DataNotFoundError extends Error { 17 | constructor(message, originalError) { 18 | super(message); 19 | this.name = 'DataNotFoundError'; 20 | this.originalError = originalError; 21 | } 22 | } 23 | 24 | class BookmarksDataNotValidError extends Error { 25 | constructor(message) { 26 | super(message); 27 | this.name = 'BookmarksDataNotValidError'; 28 | } 29 | } 30 | 31 | class RepositoryNotFoundError extends Error { 32 | constructor(message, originalError) { 33 | super(message); 34 | this.name = 'RepositoryNotFoundError'; 35 | this.originalError = originalError; 36 | } 37 | } 38 | 39 | export { 40 | AuthenticationError, DataNotFoundError, BookmarksDataNotValidError, BookmarkSourceNotConfiguredError, RepositoryNotFoundError, 41 | }; 42 | -------------------------------------------------------------------------------- /src/utils/github-bookmarks-loader.js: -------------------------------------------------------------------------------- 1 | import {Octokit} from '@octokit/rest'; 2 | import {retry} from '@octokit/plugin-retry'; 3 | import optionsStorage from '@/utils/options-storage.js'; 4 | import { 5 | AuthenticationError, DataNotFoundError, BookmarkSourceNotConfiguredError, RepositoryNotFoundError, 6 | } from '@/utils/errors.js'; 7 | 8 | class GitHubBookmarksLoader { 9 | async load(options = {}) { 10 | const bookmarkFilesSource1 = await this.loadFromSource('source1', options); 11 | const {source2_active} = await optionsStorage.getAll(); // eslint-disable-line camelcase 12 | let bookmarkFilesSource2 = null; 13 | if (source2_active) { // eslint-disable-line camelcase 14 | bookmarkFilesSource2 = await this.loadFromSource('source2', options); 15 | } 16 | 17 | if (bookmarkFilesSource1 === null && bookmarkFilesSource2 === null) { 18 | return null; 19 | } 20 | 21 | const bookmarkFiles = []; 22 | if (bookmarkFilesSource1 !== null) { 23 | bookmarkFiles.push(...bookmarkFilesSource1); 24 | } 25 | 26 | if (bookmarkFilesSource2 !== null) { 27 | bookmarkFiles.push(...bookmarkFilesSource2); 28 | } 29 | 30 | return bookmarkFiles; 31 | } 32 | 33 | async loadFromSource(sourceId, {force = false, cacheEtag = true} = {}) { 34 | const options = await optionsStorage.getAll(); 35 | const repo = options[`${sourceId}_repo`]; 36 | const owner = options[`${sourceId}_owner`]; 37 | const pat = options[`${sourceId}_pat`]; 38 | const sourcePath = options[`${sourceId}_sourcePath`]; 39 | const etag = options[`${sourceId}_etag`]; 40 | const githubApiUrl = options[`${sourceId}_githubApiUrl`]; 41 | 42 | if (!repo || !owner || !sourcePath || !pat) { 43 | throw new BookmarkSourceNotConfiguredError('Missing some or all required configuration values for the bookmark source'); 44 | } 45 | 46 | const MyOctokit = Octokit.plugin(retry); 47 | const octokit = new MyOctokit({auth: pat, baseUrl: githubApiUrl || null}); 48 | 49 | console.info(`Starting sync with GitHub using ${owner}/${repo}/${sourcePath}`); 50 | 51 | try { 52 | // Explicitly checking for the existence of the repo is slower, but enables more specific error messages 53 | // otherwise, we cannot differentiate between a wrong path and a wrong repo 54 | await octokit.rest.repos.get({ 55 | owner, 56 | repo, 57 | }); 58 | } catch (error) { 59 | if (error.status === 401) { 60 | throw new AuthenticationError('Authentication with GitHub failed. Please check your PAT.', error); 61 | } else if (error.status === 404) { 62 | throw new RepositoryNotFoundError(`The repository '${owner}/${repo}' does not exist or is not accessible with the provided PAT.`, error); 63 | } 64 | 65 | throw error; 66 | } 67 | 68 | try { 69 | const response = await octokit.rest.repos.getContent({ 70 | owner, 71 | repo, 72 | path: sourcePath, 73 | mediaType: { 74 | format: sourcePath.endsWith('.json') ? 'raw' : 'json', 75 | }, 76 | headers: etag && !force ? {'If-None-Match': etag} : {}, 77 | }); 78 | 79 | let bookmarkFileResponses = []; 80 | if (Array.isArray(response.data)) { 81 | if (response.data.length === 0) { 82 | throw new DataNotFoundError('No bookmark data found in folder.'); 83 | } 84 | 85 | const files = response.data 86 | .filter(item => item.type === 'file') 87 | .filter(file => file.name.endsWith('.json')); 88 | const contentPromises = files.map(file => octokit.rest.repos.getContent({ 89 | owner, 90 | repo, 91 | path: file.path, 92 | mediaType: { 93 | format: 'raw', 94 | }, 95 | })); 96 | 97 | bookmarkFileResponses = await Promise.all(contentPromises); 98 | } else { 99 | bookmarkFileResponses = [response]; 100 | } 101 | 102 | const bookmarkFiles = bookmarkFileResponses.map(file => JSON.parse(file.data)); 103 | 104 | if (cacheEtag) { 105 | const etagPropertyName = `${sourceId}_etag`; 106 | await optionsStorage.set({[etagPropertyName]: response.headers.etag}); 107 | } 108 | 109 | return bookmarkFiles; 110 | } catch (error) { 111 | if (error.status === 304) { 112 | console.log('No changes detected in bookmarks - nothing to sync'); 113 | return null; 114 | } 115 | 116 | if (error.status === 401) { 117 | throw new AuthenticationError('Authentication with GitHub failed. Please check your PAT.', error); 118 | } else if (error.status === 404) { 119 | throw new DataNotFoundError(`The specified bookmarks file or folder '${sourcePath}' was not found.`, error); 120 | } else { 121 | throw error; 122 | } 123 | } 124 | } 125 | } 126 | 127 | export default GitHubBookmarksLoader; 128 | -------------------------------------------------------------------------------- /src/utils/options-storage.js: -------------------------------------------------------------------------------- 1 | import OptionsSync from 'webext-options-sync'; 2 | 3 | const optionsStorage = new OptionsSync({ 4 | /* eslint-disable camelcase */ 5 | defaults: { 6 | // Source 1 7 | source1_useCustomHost: false, 8 | source1_githubApiUrl: '', 9 | source1_pat: '', 10 | source1_owner: '', 11 | source1_repo: '', 12 | source1_sourcePath: '', 13 | source1_etag: '', 14 | // Source 2 15 | source2_active: false, 16 | source2_useCustomHost: false, 17 | source2_githubApiUrl: '', 18 | source2_pat: '', 19 | source2_owner: '', 20 | source2_repo: '', 21 | source2_sourcePath: '', 22 | source2_etag: '', 23 | }, 24 | /* eslint-enable camelcase */ 25 | migrations: [ 26 | (savedOptions, defaults) => { // eslint-disable-line no-unused-vars 27 | migrateTo(savedOptions, 'useCustomHost'); 28 | migrateTo(savedOptions, 'githubApiUrl'); 29 | migrateTo(savedOptions, 'pat'); 30 | migrateTo(savedOptions, 'owner'); 31 | migrateTo(savedOptions, 'repo'); 32 | migrateTo(savedOptions, 'sourcePath'); 33 | migrateTo(savedOptions, 'etag'); 34 | }, 35 | OptionsSync.migrations.removeUnused, 36 | ], 37 | logging: true, 38 | storageType: 'local', 39 | }); 40 | 41 | function migrateTo(options, oldPropertyName) { 42 | if (!options[oldPropertyName]) { 43 | return; 44 | } 45 | 46 | const newPropertyName = `source1_${oldPropertyName}`; 47 | if (options[newPropertyName]) { 48 | return; 49 | } 50 | 51 | options[newPropertyName] = options[oldPropertyName]; 52 | delete options[oldPropertyName]; 53 | } 54 | 55 | export default optionsStorage; 56 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.wxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /wxt.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import {defineConfig, type ConfigEnv, type UserManifest} from 'wxt'; 3 | import * as pkg from './package.json'; 4 | 5 | // See https://wxt.dev/api/config.html 6 | export default defineConfig({ 7 | srcDir: 'src', 8 | manifest: generateManifest, 9 | imports: false, 10 | }); 11 | 12 | function generateManifest(env: ConfigEnv): UserManifest { 13 | const manifest: UserManifest = { 14 | name: 'Bookmark Sync for GitHub', 15 | description: pkg.description, 16 | homepage_url: 'https://github.com/frederikb/bookmarksync', 17 | permissions: [ 18 | 'storage', 19 | 'bookmarks', 20 | 'notifications', 21 | 'alarms', 22 | ], 23 | }; 24 | 25 | if (env.browser === 'firefox') { 26 | manifest.browser_specific_settings = { 27 | gecko: { 28 | id: '{883c2986-80c3-41fc-9e24-8dd91b91444e}', 29 | strict_min_version: '115.0', 30 | }, 31 | }; 32 | } 33 | 34 | if (env.manifestVersion === 2) { 35 | manifest.permissions?.push('https://api.github.com/'); 36 | } 37 | 38 | if (env.manifestVersion > 2) { 39 | manifest.host_permissions = [ 40 | 'https://api.github.com/', 41 | ]; 42 | } 43 | 44 | return manifest; 45 | } 46 | --------------------------------------------------------------------------------