├── .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 |
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 |
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 | [
][link-firefox] [
][link-firefox]
33 |
34 | [
][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 | 
135 |
136 | *Options Page* - Configure your GitHub Personal Access Token and repository details.
137 |
138 | 
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 |
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 |
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