Repository: dd3v/favbox
Branch: master
Commit: 0e723e1266ae
Files: 79
Total size: 263.7 KB
Directory structure:
gitextract_xc1kkoq6/
├── .eslintignore
├── .eslintrc.cjs
├── .gitignore
├── LICENSE
├── README.md
├── manifest.chrome.json
├── manifest.firefox.json
├── package.json
├── public/
│ └── site.webmanifest
├── src/
│ ├── assets/
│ │ └── app.css
│ ├── components/
│ │ └── app/
│ │ ├── AppBadge.vue
│ │ ├── AppBullet.vue
│ │ ├── AppButton.vue
│ │ ├── AppConfirmation.vue
│ │ ├── AppDrawer.vue
│ │ ├── AppInfiniteScroll.vue
│ │ ├── AppNotifications.vue
│ │ ├── AppProgress.vue
│ │ ├── AppRadio.vue
│ │ ├── AppSpinner.vue
│ │ └── AppTagInput.vue
│ ├── composables/
│ │ └── useColorExtraction.js
│ ├── constants/
│ │ ├── app.js
│ │ └── httpStatus.js
│ ├── ext/
│ │ ├── browser/
│ │ │ ├── app.js
│ │ │ ├── components/
│ │ │ │ ├── ASide.vue
│ │ │ │ ├── AttributeList.vue
│ │ │ │ ├── BookmarkFavicon.vue
│ │ │ │ ├── BookmarkForm.vue
│ │ │ │ ├── BookmarkLayout.vue
│ │ │ │ ├── BookmarksSync.vue
│ │ │ │ ├── CommandPalette.vue
│ │ │ │ ├── DatePicker.vue
│ │ │ │ ├── FolderTree.vue
│ │ │ │ ├── FolderTreeItem.vue
│ │ │ │ ├── SearchTerm.vue
│ │ │ │ ├── SortDirection.vue
│ │ │ │ ├── TextEditor.vue
│ │ │ │ ├── ThemeMode.vue
│ │ │ │ ├── ViewMode.vue
│ │ │ │ └── card/
│ │ │ │ ├── BookmarkCard.vue
│ │ │ │ ├── DuplicateCard.vue
│ │ │ │ ├── HealthCheckCard.vue
│ │ │ │ ├── PinnedCard.vue
│ │ │ │ └── type/
│ │ │ │ ├── CardView.vue
│ │ │ │ ├── ListView.vue
│ │ │ │ └── MasonryView.vue
│ │ │ ├── index.html
│ │ │ ├── layouts/
│ │ │ │ └── AppLayout.vue
│ │ │ ├── router.js
│ │ │ └── views/
│ │ │ ├── BookmarksView.vue
│ │ │ ├── DuplicatesView.vue
│ │ │ ├── HealthCheckView.vue
│ │ │ └── NotesView.vue
│ │ ├── content/
│ │ │ └── content.js
│ │ ├── popup/
│ │ │ ├── App.vue
│ │ │ ├── PopupView.vue
│ │ │ ├── components/
│ │ │ │ └── BookmarkForm.vue
│ │ │ ├── index.html
│ │ │ └── main.js
│ │ └── sw/
│ │ ├── index.js
│ │ ├── ping.js
│ │ └── sync.js
│ ├── index.html
│ ├── parser/
│ │ └── metadata.js
│ ├── services/
│ │ ├── browserBookmarks.js
│ │ ├── hash.js
│ │ ├── httpClient.js
│ │ └── tags.js
│ └── storage/
│ ├── attribute.js
│ ├── bookmark.js
│ └── idb/
│ └── connection.js
├── tests/
│ ├── integration/
│ │ └── fetch.spec.js
│ └── unit/
│ ├── browserBookmarks.spec.js
│ ├── hash.spec.js
│ ├── metadataParser.spec.js
│ └── tagHelper.spec.js
├── vite.config.firefox.js
└── vite.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintignore
================================================
node_modules/
dist/
public/
*.min.js
package-lock.json
================================================
FILE: .eslintrc.cjs
================================================
module.exports = {
env: {
browser: true,
es2021: true,
webextensions: true,
node: true,
},
extends: ['plugin:vue/recommended', 'airbnb-base', 'plugin:jsdoc/recommended', 'plugin:vuejs-accessibility/recommended'],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['vue', 'import', 'jsdoc', 'vuejs-accessibility'],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'max-len': 'off',
'vue/no-v-model-argument': 'off',
'no-plusplus': 'off',
'no-continue': 'off',
'class-methods-use-this': 'off',
'no-unused-expressions': 'off',
'object-curly-newline': 'off',
'no-new': 'off',
'no-bitwise': 'off',
'no-restricted-syntax': [
'error',
'ForInStatement',
'LabeledStatement',
'WithStatement',
],
'no-use-before-define': ['error', { functions: false }],
'no-param-reassign': ['error', { props: false }],
'import/no-unresolved': ['error', { ignore: ['^~icons/', '^floating-vue', '^floating-vue/dist/style', '^floating-vue/style', '\\.css$', '\\.scss$', '\\.sass$'] }],
'import/extensions': ['error', { ignore: ['^~icons/', '^floating-vue', '^floating-vue/dist/style', '^floating-vue/style', '\\.css$', '\\.scss$', '\\.sass$'] }],
// JSDoc rules
'jsdoc/require-jsdoc': 'off',
'jsdoc/require-param-description': 'off',
'jsdoc/require-returns-description': 'off',
'jsdoc/require-param': 'off',
'jsdoc/require-returns': 'off',
'no-await-in-loop': 'off',
// Accessibility rules
'vuejs-accessibility/alt-text': 'error',
'vuejs-accessibility/anchor-has-content': 'error',
'vuejs-accessibility/aria-props': 'error',
'vuejs-accessibility/aria-unsupported-elements': 'error',
'vuejs-accessibility/click-events-have-key-events': 'error',
'vuejs-accessibility/heading-has-content': 'error',
'vuejs-accessibility/iframe-has-title': 'error',
'vuejs-accessibility/interactive-supports-focus': 'error',
'vuejs-accessibility/label-has-for': 'error',
'vuejs-accessibility/media-has-caption': 'warn',
'vuejs-accessibility/mouse-events-have-key-events': 'error',
'vuejs-accessibility/no-access-key': 'error',
'vuejs-accessibility/no-autofocus': 'error',
'vuejs-accessibility/no-distracting-elements': 'error',
'vuejs-accessibility/no-redundant-roles': 'error',
'vuejs-accessibility/role-has-required-aria-props': 'error',
'vuejs-accessibility/tabindex-no-positive': 'error',
'vuejs-accessibility/no-static-element-interactions': 'error',
'vuejs-accessibility/form-control-has-label': 'error',
},
settings: {
'import/resolver': {
alias: {
map: [['@', './src']],
extensions: ['.ts', '.js', '.jsx', '.tsx', '.json'],
},
},
'import/ignore': [
],
},
ignorePatterns: ['vite.config.js', 'vite.config.firefox.js', 'src/ext/browser/app.js'],
};
================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2025 Magalyas Dmitry
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# FavBox
FavBox is a local-first **experimental** browser extension that enhances and simplifies bookmark management without cloud storage or third-party services. It extends your browser's native bookmarking features.
Key features:
🔄 Syncs with your browser profile \
🔒 No third‑party data sharing. No ads. No tracking. \
🎨 Minimalist, clean UI\
🏷️ Tag support for easy organization\
🔍 Advanced search, sorting, and filtering by tags, domains, folders, and keywords\
🌁 Multiple display modes\
🌗 Light and dark themes\
🗑️ Detects broken and duplicate bookmarks\
⌨️ Hotkeys for quick search access\
🗒️ Local notes support\
❤️ Free and open source
### Concept

### Implementation
FavBox scans all bookmarks in the browser, then makes requests to the saved pages and extracts data from them such as title, description, image, and meta tags to improve the search. All the data is stored in local storage IndexedDB. The extension also tracks all browser events related to bookmarks and synchronizes the data. It only extends the standard functionality and does not attempt to replace it. You can work with bookmarks both through the extension and the native browser’s built-in bookmark features.
FavBox is a fully local application. To keep tags synced across devices, it uses a trick. Since bookmarks are synchronized between devices, to keep tags synchronized, the app adds them to the page title.
For example, if you have a bookmark titled `Google Chrome — Wikipedia`, to save tags across devices, extension appends them to the title like this:
`Google Chrome — Wikipedia 🏷 #wiki #browser`
This way, your tags become available on other devices without using any cloud services — only through the standard Google Chrome profile sync.
```
├── public # Static assets (icons, etc.)
│ └── icons
├── src # Source code
│ ├── assets # Global styles
│ ├── components # Shared UI components
│ │ └── app
│ ├── composables # Vue composables
│ ├── constants # Application constants
│ ├── ext # Browser extension
│ │ ├── browser # FavBox main app
│ │ │ ├── components
│ │ │ ├── layouts
│ │ │ └── views
│ │ ├── content # Content scripts
│ │ ├── popup # Extension popup
│ │ └── sw # Service worker
│ ├── parser # HTML metadata parser
│ ├── services # Utility services (HTTP client, bookmarks API, tags, hash)
│ └── storage # IndexedDB storage
│ └── idb
└── tests
├── integration
└── unit
```
### Permissions
| Permission | Why needed |
|------------|------------|
| `bookmarks` | Read and manage bookmarks|
| `activeTab` | Capture page screenshot for visual previews |
| `tabs` | Get current tab info when saving bookmarks |
| `storage` | Store sync status and extension settings |
| `alarms` | Keep service worker alive for background sync |
| `contextMenus` | Add "Save to FavBox" to right-click menu |
| `` | Fetch page metadata (title, description, favicon) |
### Building
1. `pnpm run build` to build into `dist`
2. Enable dev mode in `chrome://extensions/` and `Load unpacked` extension
### Commands
- **`dev`** Start development server
- **`dev:firefox`** Firefox development build (WIP)
- **`build`** Production build
- **`test:unit`** Run unit tests
- **`test:integration`** Run integration tests
### TODO
- Use SQLite Wasm for storage (ideal for future experiments)
- Improve transaction implementation (ensure reliability & better performance)
- The extension already uses a polyfill to maintain compatibility with other browsers. It would be good to test this in Firefox. (WIP)
================================================
FILE: manifest.chrome.json
================================================
{
"manifest_version": 3,
"name": "FavBox",
"description": "A clean, modern bookmark app — local-first by design.",
"version": "2.1.5",
"permissions": [
"bookmarks",
"activeTab",
"tabs",
"storage",
"alarms",
"contextMenus"
],
"host_permissions": [
""
],
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_popup": "ext/popup/index.html"
},
"commands": {
"_execute_action": {
"suggested_key": {
"windows": "Ctrl+Shift+Y",
"mac": "Command+Shift+Y",
"chromeos": "Ctrl+Shift+U",
"linux": "Ctrl+Shift+J"
}
}
},
"background": {
"service_worker": "ext/sw/index.js",
"type": "module"
},
"content_scripts": [
{
"matches": [
""
],
"js": [
"ext/content/content.js"
],
"run_at": "document_end"
}
],
"web_accessible_resources": [
{
"resources": [
"ext/browser/index.html"
],
"matches": [
""
]
}
]
}
================================================
FILE: manifest.firefox.json
================================================
{
"manifest_version": 3,
"name": "FavBox",
"description": "A clean, modern bookmark app — local-first by design.",
"version": "2.1.5",
"permissions": [
"bookmarks",
"activeTab",
"tabs",
"storage",
"alarms",
"contextMenus"
],
"host_permissions": [
""
],
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png"
},
"default_title": "FavBox",
"default_popup": "ext/popup/index.html",
"theme_icons": [
{
"light": "icons/icon16.png",
"dark": "icons/icon16.png",
"size": 16
},
{
"light": "icons/icon32.png",
"dark": "icons/icon32.png",
"size": 32
}
]
},
"background": {
"scripts": [
"ext/sw/index.js"
],
"persistent": false
},
"content_scripts": [
{
"matches": [
""
],
"js": [
"ext/content/content.js"
],
"run_at": "document_end"
}
],
"web_accessible_resources": [
{
"resources": [
"ext/browser/index.html"
],
"matches": [
""
]
}
]
}
================================================
FILE: package.json
================================================
{
"name": "favbox",
"version": "2.1.5",
"private": false,
"type": "module",
"scripts": {
"dev": "vite",
"dev:firefox": "vite build --mode development -c vite.config.firefox.js",
"build": "vite build",
"test:unit": "vitest --environment jsdom --root tests/unit --disableConsoleIntercept",
"test:integration": "vitest --environment jsdom --root tests/integration --disableConsoleIntercept",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix"
},
"dependencies": {
"@fontsource/sn-pro": "^5.2.5",
"@headlessui/vue": "^1.7.21",
"@number-flow/vue": "^0.4.3",
"@tailwindcss/vite": "^4.1.11",
"@tiptap/extension-bold": "^2.9.1",
"@tiptap/extension-highlight": "^2.9.1",
"@tiptap/extension-italic": "^2.9.1",
"@tiptap/extension-typography": "^2.9.1",
"@tiptap/extension-underline": "^2.9.1",
"@tiptap/pm": "^2.9.1",
"@tiptap/starter-kit": "^2.9.1",
"@tiptap/vue-3": "^2.9.1",
"@vuepic/vue-datepicker": "^11.0.2",
"@vueuse/core": "^13.5.0",
"@zanmato/vue3-treeselect": "^0.4.1",
"fast-average-color": "^9.5.0",
"floating-vue": "^5.2.2",
"jsstore": "^4.9.0",
"linkedom": "^0.18.11",
"node-vibrant": "^4.0.3",
"notiwind": "^2.0.2",
"vue": "^3.5.13",
"vue-next-masonry": "^1.1.3",
"vue-router": "^4.5.0",
"webextension-polyfill": "^0.12.0"
},
"devDependencies": {
"@babel/eslint-parser": "^7.28.0",
"@crxjs/vite-plugin": "^2.0.0-beta.26",
"@iconify/json": "^2.2.356",
"@rushstack/eslint-patch": "^1.10.2",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.13",
"@types/dompurify": "^3.0.5",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/test-utils": "^2.4.5",
"autoprefixer": "^10.4.21",
"eslint": "^8.57.1",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsdoc": "^48.11.0",
"eslint-plugin-vue": "^9.33.0",
"eslint-plugin-vuejs-accessibility": "^2.4.1",
"file-loader": "^6.2.0",
"globals": "^16.3.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"terser": "^5.31.0",
"unplugin-auto-import": "^19.3.0",
"unplugin-icons": "^22.1.0",
"vite": "^7.1.11",
"vite-plugin-eslint": "^1.8.1",
"vitest": "^3.0.5",
"vue-eslint-parser": "^10.2.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/dd3v/favbox.git"
},
"keywords": [
"chrome extension",
"bookmarks"
],
"author": "Magalyas Dmitry",
"license": "MIT",
"bugs": {
"url": "https://github.com/dd3v/favbox/issues"
},
"homepage": "https://github.com/dd3v/favbox#readme"
}
================================================
FILE: public/site.webmanifest
================================================
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
================================================
FILE: src/assets/app.css
================================================
@import "tailwindcss";
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where(.dark, .dark *));
@layer base {
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@theme {
--font-sans: "SN Pro", sans-serif;
--color-soft-50: oklch(0.999 0.001 0);
--color-soft-100: oklch(0.995 0.002 0);
--color-soft-200: oklch(0.99 0.003 0);
--color-soft-300: oklch(0.98 0.004 0);
--color-soft-400: oklch(0.95 0.005 0);
--color-soft-500: oklch(0.9 0.006 0);
--color-soft-600: oklch(0.85 0.007 0);
--color-soft-700: oklch(0.8 0.008 0);
--color-soft-800: oklch(0.75 0.009 0);
--color-soft-900: oklch(0.7 0.01 0);
--color-gray-50: oklch(0.985 0 0);
--color-gray-100: oklch(0.967 0.001 286.375);
--color-gray-200: oklch(0.92 0.004 286.32);
--color-gray-300: oklch(0.871 0.006 286.286);
--color-gray-400: oklch(0.705 0.015 286.067);
--color-gray-500: oklch(0.552 0.016 285.938);
--color-gray-600: oklch(0.442 0.017 285.786);
--color-gray-700: oklch(0.37 0.013 285.805);
--color-gray-800: oklch(0.274 0.006 286.033);
--color-gray-900: oklch(0.21 0.006 285.885);
--color-gray-950: oklch(0.141 0.005 285.823);
}
@layer utilities {
:root {
--scrollbar-thumb: rgba(0, 0, 0, 0.2);
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
}
.dark {
--scrollbar-thumb: rgba(255, 255, 255, 0.2);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.3);
}
::-webkit-scrollbar {
width: 1px;
height: 1px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
}
@supports(animation-timeline: view()) {
@keyframes fade-in-on-enter--fade-out-on-exit {
entry 0% {
opacity: 0;
transform: translateY(100%);
}
entry 100% {
opacity: 1;
transform: translateY(0);
}
exit 0% {
opacity: 1;
transform: translateY(0);
}
exit 100% {
opacity: 0;
transform: translateY(-100%);
}
}
.list-view>ul>li {
animation: linear fade-in-on-enter--fade-out-on-exit;
animation-timeline: view();
}
}
.vue3-treeselect__single-value {
@apply!text-black dark: !text-white;
}
.vue3-treeselect:not(.vue3-treeselect--disabled):not(.vue3-treeselect--focused) .vue3-treeselect__control:hover {
@apply !border-gray-200 dark:!border-neutral-700;
}
.vue3-treeselect__control {
@apply w-full !border-gray-200 !text-xs !text-black !shadow-sm !outline-none focus:!border-gray-300 focus:!ring-0 dark:!border-neutral-800 dark:!bg-neutral-900 dark:!text-white dark:focus:!border-neutral-700;
}
.vue3-treeselect__input {
@apply !text-xs !text-black dark:!text-white;
}
.vue3-treeselect__input:focus {
@apply !ring-0 !outline-none !shadow-none !text-xs;
}
.vue3-treeselect__menu {
@apply !bg-white !shadow-sm !text-xs !border-t-0 !border-solid !border-gray-200 dark:!border-neutral-700 !ring-0 focus:!outline-none dark:!bg-neutral-900 dark:!text-white;
}
.vue3-treeselect .vue3-treeselect__list div {
@apply !leading-[2.5] !py-0 !my-0;
}
.vue3-treeselect__option--highlight {
@apply !bg-gray-100 dark:!bg-neutral-800;
}
.vue3-treeselect__option vue3-treeselect__option--highlight {
@apply !bg-gray-100 dark:!bg-neutral-800;
}
.vue3-treeselect--open .vue3-treeselect__control {
@apply dark:!border-neutral-700;
}
.vue3-treeselect--single .vue3-treeselect__option--selected {
@apply !bg-gray-100 dark:!bg-neutral-800;
}
.vue3-treeselect--focused:not(.vue3-treeselect--open) .vue3-treeselect__control {
@apply !border-gray-200 dark:!border-neutral-700 !shadow-none;
}
.vue3-treeselect__single-value {
@apply !text-black dark:!text-white;
}
:root .dp__theme_light {
--dp-background-color: #fff;
--dp-text-color: var(--color-gray-900);
--dp-hover-color: var(--color-gray-100);
--dp-hover-text-color: var(--color-gray-900);
--dp-primary-color: var(--color-gray-600);
--dp-primary-text-color: #fff;
--dp-border-color: var(--color-gray-200);
--dp-menu-border-color: var(--color-gray-200);
--dp-font-family: "SN Pro", sans-serif;
--dp-font-size: 0.75rem;
}
:root .dp__theme_dark {
--dp-background-color: var(--color-gray-950);
--dp-text-color: var(--color-gray-100);
--dp-hover-color: var(--color-gray-800);
--dp-hover-text-color: var(--color-gray-100);
--dp-primary-color: var(--color-gray-400);
--dp-primary-text-color: var(--color-gray-900);
--dp-border-color: var(--color-gray-700);
--dp-menu-border-color: var(--color-gray-700);
--dp-font-family: "SN Pro", sans-serif;
--dp-font-size: 0.75rem;
}
================================================
FILE: src/components/app/AppBadge.vue
================================================
================================================
FILE: src/components/app/AppBullet.vue
================================================
================================================
FILE: src/components/app/AppButton.vue
================================================
================================================
FILE: src/components/app/AppConfirmation.vue
================================================
================================================
FILE: src/components/app/AppDrawer.vue
================================================
================================================
FILE: src/components/app/AppInfiniteScroll.vue
================================================
================================================
FILE: src/components/app/AppNotifications.vue
================================================
================================================
FILE: src/components/app/AppProgress.vue
================================================
================================================
FILE: src/components/app/AppRadio.vue
================================================
{{ label }}
================================================
FILE: src/components/app/AppSpinner.vue
================================================
================================================
FILE: src/components/app/AppTagInput.vue
================================================
================================================
FILE: src/composables/useColorExtraction.js
================================================
import { FastAverageColor } from 'fast-average-color';
import { ref } from 'vue';
export default function useColorExtraction() {
const placeholder = ref({
background: 'radial-gradient(ellipse at 50% 40%, rgba(200, 200, 220, 0.5) 0%, rgba(180, 180, 200, 0.3) 50%, rgba(160, 160, 180, 0.15) 100%)',
transition: 'background 0.8s ease',
});
const extract = async (url, cacheKey) => {
if (!url) return;
const cached = localStorage.getItem(cacheKey);
if (cached) {
const style = JSON.parse(cached);
placeholder.value = style.placeholder;
return;
}
try {
const fac = new FastAverageColor();
const color = await fac.getColorAsync(url);
const [r, g, b] = color.value;
const saturate = (v) => Math.min(255, Math.round(v * 1.2));
const style = {
placeholder: {
background: `radial-gradient(ellipse at 50% 40%, rgba(${saturate(r)}, ${saturate(g)}, ${saturate(b)}, 0.5) 0%, rgba(${r}, ${g}, ${b}, 0.3) 50%, rgba(${r}, ${g}, ${b}, 0.15) 100%)`,
transition: 'background 0.8s ease',
},
};
localStorage.setItem(cacheKey, JSON.stringify(style));
placeholder.value = style.placeholder;
} catch (e) {
console.warn('Color extraction failed, using default', e);
}
};
return { placeholder, extract };
}
================================================
FILE: src/constants/app.js
================================================
export const PAGINATION_LIMIT = 100;
export const NOTIFICATION_DURATION = 3000;
================================================
FILE: src/constants/httpStatus.js
================================================
export const HTTP_STATUS = {
CONTINUE: 100,
SWITCHING_PROTOCOLS: 101,
PROCESSING: 102,
EARLY_HINTS: 103,
OK: 200,
CREATED: 201,
ACCEPTED: 202,
NON_AUTHORITATIVE_INFORMATION: 203,
NO_CONTENT: 204,
RESET_CONTENT: 205,
PARTIAL_CONTENT: 206,
MULTI_STATUS: 207,
ALREADY_REPORTED: 208,
IM_USED: 226,
MULTIPLE_CHOICES: 300,
MOVED_PERMANENTLY: 301,
FOUND: 302,
SEE_OTHER: 303,
NOT_MODIFIED: 304,
USE_PROXY: 305,
TEMPORARY_REDIRECT: 307,
PERMANENT_REDIRECT: 308,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
PAYMENT_REQUIRED: 402,
FORBIDDEN: 403,
NOT_FOUND: 404,
METHOD_NOT_ALLOWED: 405,
NOT_ACCEPTABLE: 406,
PROXY_AUTHENTICATION_REQUIRED: 407,
REQUEST_TIMEOUT: 408,
CONFLICT: 409,
GONE: 410,
LENGTH_REQUIRED: 411,
PRECONDITION_FAILED: 412,
CONTENT_TOO_LARGE: 413,
URI_TOO_LONG: 414,
UNSUPPORTED_MEDIA_TYPE: 415,
RANGE_NOT_SATISFIABLE: 416,
EXPECTATION_FAILED: 417,
IM_A_TEAPOT: 418,
MISDIRECTED_REQUEST: 421,
UNPROCESSABLE_ENTITY: 422,
LOCKED: 423,
FAILED_DEPENDENCY: 424,
TOO_EARLY: 425,
UPGRADE_REQUIRED: 426,
PRECONDITION_REQUIRED: 428,
TOO_MANY_REQUESTS: 429,
REQUEST_HEADER_FIELDS_TOO_LARGE: 431,
INTERNAL_SERVER_ERROR: 500,
NOT_IMPLEMENTED: 501,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
GATEWAY_TIMEOUT: 504,
HTTP_VERSION_NOT_SUPPORTED: 505,
VARIANT_ALSO_NEGOTIATES: 506,
INSUFFICIENT_STORAGE: 507,
LOOP_DETECTED: 508,
NOT_EXTENDED: 510,
NETWORK_AUTHENTICATION_REQUIRED: 511,
UNKNOWN_ERROR: 520,
WEB_SERVER_IS_DOWN: 521,
NETWORK_TIMEOUT_ERROR: 599,
};
export const STATUS_MESSAGE = new Map([
[HTTP_STATUS.CONTINUE, 'Continue'],
[HTTP_STATUS.SWITCHING_PROTOCOLS, 'Switching Protocols'],
[HTTP_STATUS.PROCESSING, 'Processing'],
[HTTP_STATUS.EARLY_HINTS, 'Early Hints'],
[HTTP_STATUS.OK, 'OK'],
[HTTP_STATUS.CREATED, 'Created'],
[HTTP_STATUS.ACCEPTED, 'Accepted'],
[HTTP_STATUS.NON_AUTHORITATIVE_INFORMATION, 'Non-Authoritative Information'],
[HTTP_STATUS.NO_CONTENT, 'No Content'],
[HTTP_STATUS.RESET_CONTENT, 'Reset Content'],
[HTTP_STATUS.PARTIAL_CONTENT, 'Partial Content'],
[HTTP_STATUS.MULTI_STATUS, 'Multi-Status'],
[HTTP_STATUS.ALREADY_REPORTED, 'Already Reported'],
[HTTP_STATUS.IM_USED, 'IM Used'],
[HTTP_STATUS.MULTIPLE_CHOICES, 'Multiple Choices'],
[HTTP_STATUS.MOVED_PERMANENTLY, 'Moved Permanently'],
[HTTP_STATUS.FOUND, 'Found'],
[HTTP_STATUS.SEE_OTHER, 'See Other'],
[HTTP_STATUS.NOT_MODIFIED, 'Not Modified'],
[HTTP_STATUS.USE_PROXY, 'Use Proxy'],
[HTTP_STATUS.TEMPORARY_REDIRECT, 'Temporary Redirect'],
[HTTP_STATUS.PERMANENT_REDIRECT, 'Permanent Redirect'],
[HTTP_STATUS.BAD_REQUEST, 'Bad Request'],
[HTTP_STATUS.UNAUTHORIZED, 'Unauthorized'],
[HTTP_STATUS.PAYMENT_REQUIRED, 'Payment Required'],
[HTTP_STATUS.FORBIDDEN, 'Forbidden'],
[HTTP_STATUS.NOT_FOUND, 'Not Found'],
[HTTP_STATUS.METHOD_NOT_ALLOWED, 'Method Not Allowed'],
[HTTP_STATUS.NOT_ACCEPTABLE, 'Not Acceptable'],
[HTTP_STATUS.PROXY_AUTHENTICATION_REQUIRED, 'Proxy Authentication Required'],
[HTTP_STATUS.REQUEST_TIMEOUT, 'Request Timeout'],
[HTTP_STATUS.CONFLICT, 'Conflict'],
[HTTP_STATUS.GONE, 'Gone'],
[HTTP_STATUS.LENGTH_REQUIRED, 'Length Required'],
[HTTP_STATUS.PRECONDITION_FAILED, 'Precondition Failed'],
[HTTP_STATUS.CONTENT_TOO_LARGE, 'Content Too Large'],
[HTTP_STATUS.URI_TOO_LONG, 'URI Too Long'],
[HTTP_STATUS.UNSUPPORTED_MEDIA_TYPE, 'Unsupported Media Type'],
[HTTP_STATUS.RANGE_NOT_SATISFIABLE, 'Range Not Satisfiable'],
[HTTP_STATUS.EXPECTATION_FAILED, 'Expectation Failed'],
[HTTP_STATUS.IM_A_TEAPOT, 'I\'m a teapot'],
[HTTP_STATUS.MISDIRECTED_REQUEST, 'Misdirected Request'],
[HTTP_STATUS.UNPROCESSABLE_ENTITY, 'Unprocessable Entity'],
[HTTP_STATUS.LOCKED, 'Locked'],
[HTTP_STATUS.FAILED_DEPENDENCY, 'Failed Dependency'],
[HTTP_STATUS.TOO_EARLY, 'Too Early'],
[HTTP_STATUS.UPGRADE_REQUIRED, 'Upgrade Required'],
[HTTP_STATUS.PRECONDITION_REQUIRED, 'Precondition Required'],
[HTTP_STATUS.TOO_MANY_REQUESTS, 'Too Many Requests'],
[HTTP_STATUS.REQUEST_HEADER_FIELDS_TOO_LARGE, 'Request Header Fields Too Large'],
[HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Internal Server Error'],
[HTTP_STATUS.NOT_IMPLEMENTED, 'Not Implemented'],
[HTTP_STATUS.BAD_GATEWAY, 'Bad Gateway'],
[HTTP_STATUS.SERVICE_UNAVAILABLE, 'Service Unavailable'],
[HTTP_STATUS.GATEWAY_TIMEOUT, 'Gateway Timeout'],
[HTTP_STATUS.HTTP_VERSION_NOT_SUPPORTED, 'HTTP Version Not Supported'],
[HTTP_STATUS.VARIANT_ALSO_NEGOTIATES, 'Variant Also Negotiates'],
[HTTP_STATUS.INSUFFICIENT_STORAGE, 'Insufficient Storage'],
[HTTP_STATUS.LOOP_DETECTED, 'Loop Detected'],
[HTTP_STATUS.NOT_EXTENDED, 'Not Extended'],
[HTTP_STATUS.NETWORK_AUTHENTICATION_REQUIRED, 'Network Authentication Required'],
[HTTP_STATUS.UNKNOWN_ERROR, 'Unknown error. Data retrieval failed. Check bookmarks manually; data may be incomplete.'],
[HTTP_STATUS.WEB_SERVER_IS_DOWN, ' Web server is down'],
[HTTP_STATUS.NETWORK_TIMEOUT_ERROR, 'Network Timeout Error'],
]);
================================================
FILE: src/ext/browser/app.js
================================================
import { createApp } from 'vue';
import masonry from 'vue-next-masonry';
import Notifications from 'notiwind';
import FloatingVue from 'floating-vue';
import router from './router';
import AppLayout from './layouts/AppLayout.vue';
import 'floating-vue/dist/style.css';
import '@zanmato/vue3-treeselect/dist/vue3-treeselect.min.css';
import '@fontsource/sn-pro';
import '@vuepic/vue-datepicker/dist/main.css';
import '@/assets/app.css';
const app = createApp(AppLayout)
.use(router)
.use(masonry)
.use(Notifications)
.use(FloatingVue);
app.mount('#app');
================================================
FILE: src/ext/browser/components/ASide.vue
================================================
================================================
FILE: src/ext/browser/components/AttributeList.vue
================================================
⌘
/
{{ key.charAt(0).toUpperCase() + key.slice(1) }}
{{ key }}
================================================
FILE: src/ext/browser/components/BookmarkFavicon.vue
================================================
================================================
FILE: src/ext/browser/components/BookmarkForm.vue
================================================
================================================
FILE: src/ext/browser/components/BookmarkLayout.vue
================================================
================================================
FILE: src/ext/browser/components/BookmarksSync.vue
================================================
Scanning your bookmarks..
The app is scanning your bookmarks and gathering information about the pages to make everything run smoother and faster.
This process may take a little time depending on how many bookmarks you have, your network and device performance.
Thank you for your patience!
%
================================================
FILE: src/ext/browser/components/CommandPalette.vue
================================================
================================================
FILE: src/ext/browser/components/DatePicker.vue
================================================
================================================
FILE: src/ext/browser/components/FolderTree.vue
================================================
================================================
FILE: src/ext/browser/components/FolderTreeItem.vue
================================================
{{ folder.label }}
{{ folder.count }}
================================================
FILE: src/ext/browser/components/SearchTerm.vue
================================================
⌥
K
!modelValue.some(e => e.key === n.key && e.value === n.value))])"
/>
================================================
FILE: src/ext/browser/components/SortDirection.vue
================================================
================================================
FILE: src/ext/browser/components/TextEditor.vue
================================================
================================================
FILE: src/ext/browser/components/ThemeMode.vue
================================================
================================================
FILE: src/ext/browser/components/ViewMode.vue
================================================
Gallery
Cards
List
================================================
FILE: src/ext/browser/components/card/BookmarkCard.vue
================================================
================================================
FILE: src/ext/browser/components/card/DuplicateCard.vue
================================================
================================================
FILE: src/ext/browser/components/card/HealthCheckCard.vue
================================================
{{ bookmark.httpStatus }}
================================================
FILE: src/ext/browser/components/card/PinnedCard.vue
================================================
{{ bookmark.domain }}
Last viewed:
{{ new Date(bookmark.updatedAt).toLocaleString() }}
================================================
FILE: src/ext/browser/components/card/type/CardView.vue
================================================
{{ bookmark.domain }}
{{ new Date(bookmark.dateAdded).toISOString().slice(0, 10) }}
================================================
FILE: src/ext/browser/components/card/type/ListView.vue
================================================
================================================
FILE: src/ext/browser/components/card/type/MasonryView.vue
================================================
================================================
FILE: src/ext/browser/index.html
================================================
FavBox
================================================
FILE: src/ext/browser/layouts/AppLayout.vue
================================================
================================================
FILE: src/ext/browser/router.js
================================================
import { createWebHashHistory, createRouter } from 'vue-router';
const routes = [
{
path: '/bookmarks',
alias: '/ext/browser/index.html',
name: 'BookmarksView',
component: () => import('./views/BookmarksView.vue'),
meta: {
page: 1,
},
},
{
path: '/bookmarks/:id',
name: 'BookmarkDetailView',
component: () => import('./views/BookmarksView.vue'),
meta: {
page: 1,
},
},
{
path: '/notes',
name: 'NotesView',
component: () => import('./views/NotesView.vue'),
meta: { page: 2 },
},
{
path: '/health-check',
name: 'HealthCheckView',
component: () => import('./views/HealthCheckView.vue'),
meta: { page: 3 },
},
{
path: '/duplicates',
name: 'DuplicatesView',
component: () => import('./views/DuplicatesView.vue'),
meta: { page: 4 },
},
{
path: '/',
redirect: '/bookmarks',
},
{
path: '/:catchAll(.*)',
redirect: '/bookmarks',
},
];
const router = createRouter({
history: createWebHashHistory(),
routes,
});
export default router;
================================================
FILE: src/ext/browser/views/BookmarksView.vue
================================================
Search
Folders
loadAttributes({ skip, append: true })"
/>
🔍 No bookmarks match your search. Try changing the filters or keywords.
Edit Bookmark
Delete bookmark
Are you sure you want to delete this bookmark? This action cannot be undone. Removing the bookmark from FavBox
will also delete it from your browser.
Cancel
Delete
Take a screenshot
The browser extension will open a new tab, wait for the page to load, then capture a screenshot and save the
current preview.
Cancel
Ok
================================================
FILE: src/ext/browser/views/DuplicatesView.vue
================================================
🚀 No duplicate bookmarks found.
Delete bookmark
Are you sure you want to delete this bookmark? This action cannot be undone. Removing the bookmark from FavBox will also delete it from your browser.
Cancel
Delete
================================================
FILE: src/ext/browser/views/HealthCheckView.vue
================================================
✅ Looks like there are no broken bookmarks in your browser.
Delete bookmark
Are you sure you want to delete this bookmark? This action cannot be undone. Removing the bookmark from FavBox
will also delete it from your browser.
Cancel
Delete
================================================
FILE: src/ext/browser/views/NotesView.vue
================================================
🗂️ Your local storage is currently empty. Please pin bookmarks to get started.
📌 Select a bookmark to start editing.
================================================
FILE: src/ext/content/content.js
================================================
let port;
/**
*
*/
function connect() {
console.warn('Keep alive connection..');
port = browser.runtime.connect({ name: 'favbox' });
port.onDisconnect.addListener(connect);
port.onMessage.addListener((msg) => {
console.log('received', msg, 'from bg');
});
port.postMessage({ action: 'ping' });
}
try {
connect();
} catch (e) {
console.error('Content script error', e);
}
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'getHTML') {
const headElement = document.head;
const headHTML = headElement ? headElement.outerHTML : '';
const completeHTML = `${headHTML}`;
sendResponse({ html: completeHTML });
}
});
console.log('> Content script loaded');
================================================
FILE: src/ext/popup/App.vue
================================================
================================================
FILE: src/ext/popup/PopupView.vue
================================================
Bookmark Exists!
This page is already saved in your bookmarks
View in FavBox
================================================
FILE: src/ext/popup/components/BookmarkForm.vue
================================================
================================================
FILE: src/ext/popup/index.html
================================================
Vite App
================================================
FILE: src/ext/popup/main.js
================================================
import { createApp } from 'vue';
import App from './App.vue';
import '@/assets/app.css';
import '@zanmato/vue3-treeselect/dist/vue3-treeselect.min.css';
import '@fontsource/sn-pro';
createApp(App).mount('#app');
================================================
FILE: src/ext/sw/index.js
================================================
import BookmarkStorage from '@/storage/bookmark';
import AttributeStorage from '@/storage/attribute';
import MetadataParser from '@/parser/metadata';
import { fetchUrl } from '@/services/httpClient';
import { extractTitle, extractTags } from '@/services/tags';
import { getFoldersMap, getBookmarksFromNode } from '@/services/browserBookmarks';
import sync from './sync';
import ping from './ping';
const bookmarkStorage = new BookmarkStorage();
const attributeStorage = new AttributeStorage();
// https://developer.chrome.com/docs/extensions/develop/migrate/to-service-workers
const waitUntil = async (promise) => {
const keepAlive = setInterval(browser.runtime.getPlatformInfo, 25 * 1000);
try {
await promise;
} finally {
clearInterval(keepAlive);
}
};
browser.runtime.onInstalled.addListener(async () => {
browser.contextMenus.create({ id: 'openPopup', title: 'Bookmark this page', contexts: ['all'] });
await browser.alarms.create('healthcheck', { periodInMinutes: 0.5 });
await browser.storage.session.set({ nativeImport: false });
waitUntil(sync());
});
browser.runtime.onStartup.addListener(async () => {
console.warn('Wake up..');
await browser.storage.session.set({ nativeImport: false });
const alarm = await browser.alarms.get('healthcheck');
if (!alarm) {
await browser.alarms.create('healthcheck', { periodInMinutes: 0.5 });
}
waitUntil(sync());
});
browser.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'healthcheck') {
console.log('health check');
await browser.storage.local.set({ lastHealthCheck: Date.now() });
}
});
browser.contextMenus.onClicked.addListener((info) => {
if (info.menuItemId === 'openPopup') {
browser.action.openPopup();
}
});
// https:// developer.browser.com/docs/extensions/reference/bookmarks/#event-onCreated
browser.bookmarks.onCreated.addListener(async (id, bookmark) => {
const { nativeImport } = await browser.storage.session.get('nativeImport');
if (nativeImport === true) {
return;
}
console.time(`bookmark-created-${id}`);
console.warn('🎉 Handle bookmark create..', id, bookmark);
if (bookmark.url === undefined) {
console.warn('bad bookmark data', bookmark);
return;
}
let response = null;
let activeTab = null;
// fetch HTML from active tab (content script)
[activeTab] = await browser.tabs.query({ active: true });
try {
console.warn('activeTab', activeTab);
console.warn('requesting html from tab', activeTab);
const content = await browser.tabs.sendMessage(activeTab.id, { action: 'getHTML' });
response = { html: content?.html, error: 0 };
console.warn('response from tab', response);
} catch (e) {
console.error('No tabs. It is weird. Fetching data from internet.. 🌎', e);
response = await fetchUrl(bookmark.url, 15000);
}
try {
if (response === null) {
throw new Error('No page data: response is null');
}
const foldersMap = await getFoldersMap();
const entity = await (new MetadataParser(bookmark, response, foldersMap)).getFavboxBookmark();
if (entity.image === null && activeTab) {
try {
console.warn('📸 No image, take a screenshot', activeTab);
const screenshot = await browser.tabs.captureVisibleTab(activeTab.windowId, { format: 'jpeg', quality: 10 });
entity.image = screenshot;
} catch (e) {
console.error('📸', e);
}
}
console.log('🔖 Entity', entity);
await bookmarkStorage.create(entity);
await attributeStorage.create(entity);
refreshUserInterface();
console.log('🎉 Bookmark has been created..');
} catch (e) {
console.error('🎉', e, id, bookmark);
} finally {
response = null;
activeTab = null;
}
console.timeEnd(`bookmark-created-${id}`);
});
// https://developer.chrome.com/docs/extensions/reference/bookmarks/#event-onChanged
browser.bookmarks.onChanged.addListener(async (id, changeInfo) => {
console.time(`bookmark-changed-${id}`);
try {
const [bookmark] = await browser.bookmarks.get(id);
// folder
if (!bookmark.url) {
// Only update if title actually changed
if (changeInfo.title !== undefined) {
await bookmarkStorage.updateBookmarksFolderName(bookmark.id, changeInfo.title);
console.log('🔄 Folder has been updated..', id, changeInfo);
}
}
// bookmark
if (bookmark.url) {
const oldBookmark = await bookmarkStorage.getById(id);
// Only update if title actually changed (changeInfo.title contains new value)
if (changeInfo.title !== undefined) {
await bookmarkStorage.update(id, {
title: extractTitle(changeInfo.title),
tags: extractTags(changeInfo.title),
url: bookmark.url,
updatedAt: new Date().toISOString(),
});
const newBookmark = await bookmarkStorage.getById(id);
if (oldBookmark && newBookmark) {
await attributeStorage.update(newBookmark, oldBookmark);
}
} else {
// Title didn't change, but url or other fields might have - update only url
await bookmarkStorage.update(id, {
url: bookmark.url,
updatedAt: new Date().toISOString(),
});
}
console.log('🔄 Bookmark has been updated..', id, changeInfo);
}
} catch (e) {
console.error('🔄', e, id, changeInfo);
}
refreshUserInterface();
console.timeEnd(`bookmark-changed-${id}`);
});
// https://developer.chrome.com/docs/extensions/reference/bookmarks/#event-onMoved
browser.bookmarks.onMoved.addListener(async (id, moveInfo) => {
console.time(`bookmark-moved-${id}`);
try {
const [item] = await browser.bookmarks.get(id);
// Only process bookmarks (with url), not folders
if (item.url) {
const [folder] = await browser.bookmarks.get(moveInfo.parentId);
console.log('🗂 Bookmark has been moved..', id, moveInfo, folder);
await bookmarkStorage.update(id, {
folderName: folder.title,
folderId: folder.id,
updatedAt: new Date().toISOString(),
});
refreshUserInterface();
} else {
// Folder moved - update folderName for all bookmarks in this folder
console.log('🗂 Folder has been moved..', id, moveInfo);
await bookmarkStorage.updateBookmarksFolderName(id, item.title);
refreshUserInterface();
}
} catch (e) {
console.error('🗂', e, id, moveInfo);
}
console.timeEnd(`bookmark-moved-${id}`);
});
// https://developer.chrome.com/docs/extensions/reference/bookmarks/#event-onRemoved
browser.bookmarks.onRemoved.addListener(async (id, removeInfo) => {
console.time(`bookmark-removed-${id}`);
console.log('🗑️ Handle remove bookmark..', id, removeInfo);
// folder has been deleted..
if (removeInfo.node.children !== undefined) {
try {
const items = getBookmarksFromNode(removeInfo.node);
const bookmarksToRemove = items.map((bookmark) => bookmark.id);
if (bookmarksToRemove.length) {
await bookmarkStorage.removeByIds(bookmarksToRemove);
// Full refresh after folder deletion
const [domains, tags, keywords] = await Promise.all([
bookmarkStorage.aggregateDomains(),
bookmarkStorage.aggregateTags(),
bookmarkStorage.aggregateKeywords(),
]);
await attributeStorage.refreshFromAggregated(domains, tags, keywords, true);
console.log('🗑️ Folder has been removed..', bookmarksToRemove.length, id, removeInfo);
}
refreshUserInterface();
} catch (e) {
console.error('🗑️ Remove err', e);
}
return;
}
// single bookmark has been deleted..
try {
const bookmark = await bookmarkStorage.getById(id);
if (!bookmark) {
// Bookmark not found in storage - might have been deleted already or never synced
console.warn(`Bookmark with ID ${id} not found in storage, skipping removal.`);
return;
}
await bookmarkStorage.removeById(id);
await attributeStorage.remove(bookmark);
console.log('🗑️ Bookmark has been removed..', id, removeInfo);
} catch (e) {
console.error('🗑️', e);
}
console.timeEnd(`bookmark-removed-${id}`);
});
// https://developer.chrome.com/docs/extensions/reference/api/bookmarks#event-onImportBegan
browser.bookmarks.onImportBegan.addListener(async () => {
console.log('📄 Import bookmarks started');
await browser.storage.session.set({ nativeImport: true });
await browser.storage.session.set({ status: false });
});
// https://developer.chrome.com/docs/extensions/reference/api/bookmarks#event-onImportEnded
browser.bookmarks.onImportEnded.addListener(async () => {
console.log('📄 Import bookmarks ended');
await browser.storage.session.set({ nativeImport: false });
waitUntil(sync());
});
function refreshUserInterface() {
try {
browser.runtime.sendMessage({ action: 'refresh' });
} catch (e) {
console.error('Refresh UI listener not available', e);
}
}
ping();
================================================
FILE: src/ext/sw/ping.js
================================================
const ping = () => {
const onMessage = (msg, port) => {
console.log('received', msg, 'from', port.sender);
};
const deleteTimer = (port) => {
if (port.timer) {
clearTimeout(port.timer);
delete port.timer;
}
};
const forceReconnect = (port) => {
console.warn('Reconnect...');
deleteTimer(port);
port.disconnect();
};
// https://bugs.chromium.org/p/chromium/issues/detail?id=1152255
// https://bugs.chromium.org/p/chromium/issues/detail?id=1189678
browser.runtime.onConnect.addListener((port) => {
if (port.name !== 'favbox') return;
port.onMessage.addListener(onMessage);
port.onDisconnect.addListener(deleteTimer);
port.timer = setTimeout(forceReconnect, 30000, port);
});
};
export default ping;
================================================
FILE: src/ext/sw/sync.js
================================================
import { fetchUrl } from '@/services/httpClient';
import BookmarkStorage from '@/storage/bookmark';
import AttributeStorage from '@/storage/attribute';
import MetadataParser from '@/parser/metadata';
import { getBookmarksCount, getFoldersMap, getBookmarksIterator } from '@/services/browserBookmarks';
import hashCode from '@/services/hash';
const MAX_CONCURRENT = 50;
const BATCH_SIZE = 100;
const PROGRESS_UPDATE_INTERVAL = 3000;
const bookmarkStorage = new BookmarkStorage();
const attributeStorage = new AttributeStorage();
const sendProgress = (progress, savedCount) => {
browser.storage.session.set({ progress });
browser.runtime.sendMessage({ action: 'sync', data: { progress, savedCount } }).catch(() => {});
};
const fetchPageMetadata = async (bookmark, foldersMap) => {
const response = await fetchUrl(bookmark.url);
return (new MetadataParser(bookmark, response, foldersMap)).getFavboxBookmark();
};
const toAttribute = (key, { value, count }) => ({
key,
value: String(value).trim(),
id: hashCode(key, String(value).trim()),
count,
});
export const refreshAttributes = async () => {
console.time('refreshAttributes');
await attributeStorage.clear();
const [domains, tags, keywords] = await Promise.all([
bookmarkStorage.aggregateDomains(),
bookmarkStorage.aggregateTags(),
bookmarkStorage.aggregateKeywords(),
]);
const attributes = [
...domains.map((r) => toAttribute('domain', r)),
...tags.map((r) => toAttribute('tag', r)),
...keywords.map((r) => toAttribute('keyword', r)),
];
await attributeStorage.saveMany(attributes);
console.timeEnd('refreshAttributes');
};
const sync = async () => {
console.time('Sync time');
const browserTotal = await getBookmarksCount();
const idbTotal = await bookmarkStorage.total();
const { status } = await browser.storage.session.get('status');
await browser.storage.session.set({ browserTotal, idbTotal });
console.log(`Browser: ${browserTotal}, IDB: ${idbTotal}, Status: ${status}`);
if (browserTotal === idbTotal || status) {
await browser.storage.session.set({ status: true });
console.log('Already in sync');
return;
}
await browser.storage.session.set({ status: false });
const [foldersMap, existingIds] = await Promise.all([
getFoldersMap(),
bookmarkStorage.getAllIds().then((ids) => new Set(ids)),
]);
const browserIds = new Set();
const batch = [];
let processed = 0;
let savedCount = 0;
let lastProgressUpdate = Date.now();
const bookmarksToProcess = [];
for await (const bookmark of getBookmarksIterator()) {
browserIds.add(bookmark.id);
if (!existingIds.has(bookmark.id)) {
bookmarksToProcess.push(bookmark);
}
}
bookmarksToProcess.sort((a, b) => String(a.id).localeCompare(String(b.id)));
console.log(`To process: ${bookmarksToProcess.length}`);
const active = new Set();
const handleBookmark = async (bookmark) => {
try {
const result = await fetchPageMetadata(bookmark, foldersMap);
batch.push(result);
processed++;
const now = Date.now();
if (now - lastProgressUpdate > PROGRESS_UPDATE_INTERVAL) {
const progress = Math.round((processed / bookmarksToProcess.length) * 100);
sendProgress(progress, savedCount);
lastProgressUpdate = now;
}
} catch (error) {
console.error(`Error processing ${bookmark.url}:`, error.message);
}
};
for (const bookmark of bookmarksToProcess) {
const promise = handleBookmark(bookmark);
active.add(promise);
promise.finally(() => active.delete(promise));
if (active.size >= MAX_CONCURRENT) {
await Promise.race(active);
}
if (batch.length >= BATCH_SIZE) {
const itemsToSave = batch.splice(0, batch.length);
await bookmarkStorage.createMany(itemsToSave);
savedCount += itemsToSave.length;
console.log(`Saved batch: ${itemsToSave.length}, total: ${savedCount}`);
}
}
await Promise.all(active);
if (batch.length > 0) {
await bookmarkStorage.createMany(batch);
savedCount += batch.length;
console.log(`Saved final batch: ${batch.length}, total: ${savedCount}`);
}
const idbIds = await bookmarkStorage.getAllIds();
const toDelete = idbIds.filter((id) => !browserIds.has(id));
if (toDelete.length > 0) {
console.log(`Removing ${toDelete.length} outdated bookmarks`);
await bookmarkStorage.removeByIds(toDelete);
}
await refreshAttributes();
await browser.storage.session.set({ status: true });
sendProgress(100, savedCount);
console.timeEnd('Sync time');
console.log(`Saved: ${savedCount}`);
};
export default sync;
================================================
FILE: src/index.html
================================================
FavBox
FavBox
================================================
FILE: src/parser/metadata.js
================================================
import { parseHTML } from 'linkedom';
import { extractTitle, extractTags } from '@/services/tags';
/**
* Class for parsing bookmark metadata from HTML documents.
*/
export default class MetadataParser {
#bookmark;
#httpResponse;
#dom;
#folders;
/**
* Creates an instance of MetadataParser.
* @param {object} bookmark - The bookmark object from browser.
* @param {object} httpResponse - The HTTP response object containing HTML.
* @param {Map} [folders] - Cache map of folder IDs to names.
*/
constructor(bookmark, httpResponse, folders = new Map()) {
const { document } = parseHTML(httpResponse.html);
this.#bookmark = bookmark;
this.#dom = document;
this.#httpResponse = httpResponse;
this.#folders = folders;
}
/**
* Retrieves the title from various sources in the HTML document.
* @returns {string} The document title, or an empty string if not found.
*/
getTitle() {
// Check if bookmark title is empty or whitespace
if (!this.#bookmark.title?.trim()) {
// Try document title first
if (this.#dom.title) {
return this.#dom.title;
}
const metaSelectors = [
'meta[property="og:title"]',
'meta[name="twitter:title"]',
];
for (const selector of metaSelectors) {
const element = this.#dom.querySelector(selector);
if (element?.getAttribute('content')) {
return element.getAttribute('content');
}
}
const headingSelectors = ['h1', 'h2'];
for (const selector of headingSelectors) {
const element = this.#dom.querySelector(selector);
if (element?.textContent?.trim()) {
return element.textContent.trim();
}
}
return '';
}
return this.#bookmark.title;
}
/**
* Retrieves the description from various meta tags in the HTML document.
* @returns {string|null} The document description, or null if not found.
*/
getDescription() {
const selectors = [
'meta[property="og:description"]',
'meta[name="twitter:description"]',
'meta[name="description"]',
];
for (const selector of selectors) {
const element = this.#dom.querySelector(selector);
if (element) {
return element.getAttribute('content') ?? null;
}
}
return null;
}
/**
* Searches for preview image on the page.
* @returns {string|null} The image URL, or null if not found.
* @private
*/
#searchPagePreview() {
const htmlElem = this.#dom.querySelector([
'img[class*="hero"]',
'img[class*="banner"]',
'img[class*="cover"]',
'img[class*="featured"]',
'img[class*="preview"]',
'img[id*="post-image"]',
'article img:first-of-type',
'main img:first-of-type',
'.content img:first-of-type',
].join(','));
const src = (htmlElem?.getAttribute('content') || htmlElem?.getAttribute('href') || htmlElem?.getAttribute('src')) ?? null;
return src;
}
/**
* Retrieves the Open Graph/Meta image URL from the HTML document.
* @returns {string|null} The URL of the Open Graph image, or null if not found.
* @private
*/
#getImageFromMeta() {
const selectors = [
'meta[property="og:image"]',
'meta[property="og:image:url"]',
'meta[property="og:image:secure_url"]',
'meta[name="twitter:image"]',
'meta[name="twitter:image:src"]',
'meta[name="image"]',
'meta[name="og:image"]',
'link[rel="image_src"]',
'link[rel="preload"][as="image"]',
'meta[property="forem:logo"]',
];
for (const selector of selectors) {
const element = this.#dom.querySelector(selector);
if (element) {
const imageUrl = element.getAttribute('content') || element.getAttribute('href') || element.getAttribute('src') || null;
if (imageUrl) {
return imageUrl;
}
}
}
return null;
}
/**
* Extracts YouTube video ID from URL.
* @returns {string|null} The video ID, or null if not found.
* @private
*/
#getYouTubeVideoId() {
const { url } = this.#bookmark;
if (!url) return null;
// Match various YouTube URL formats
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([^&\n?#]+)/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match?.[1]) {
return match[1];
}
}
return null;
}
/**
* Retrieves the main image URL from the HTML document.
* @returns {string|null} The URL of the main image, or null if not found.
*/
getImage() {
const videoId = this.#getYouTubeVideoId();
if (videoId) {
return `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
}
const metaImage = this.#getImageFromMeta();
if (metaImage) {
return new URL(metaImage, this.#bookmark.url).href;
}
const src = this.#searchPagePreview();
return src ? new URL(src, this.#bookmark.url).href : null;
}
/**
* Retrieves the domain from the bookmark URL.
* @returns {string} The domain of the bookmark URL.
*/
getDomain() {
return new URL(this.#bookmark.url).hostname.replace(/^www\./, '');
}
/**
* Retrieves the favicon URL from the HTML document.
* @returns {string} The URL of the favicon or a default favicon URL if not found.
*/
getFavicon() {
let link = this.#dom.querySelector('link[rel="icon"][type="image/svg+xml"]')?.getAttribute('href');
if (!link) {
link = this.#dom.querySelector('link[rel="shortcut icon"], link[rel="icon"]')?.getAttribute('href');
}
return link ? new URL(link, this.#bookmark.url).href : `https://${this.getDomain()}/favicon.ico`;
}
/**
* Retrieves the URL of the bookmark.
* @returns {string} The URL of the bookmark.
*/
getUrl() {
return this.#bookmark.url;
}
/**
* Retrieves the keywords from the HTML document's meta tags.
* @returns {string[]} An array of keywords or an empty array if no keywords are found.
*/
getKeywords() {
const keywords = this.#dom.querySelector('meta[name="keywords"]')?.getAttribute('content');
if (!keywords) return [];
return keywords.split(',').map((keyword) => keyword.trim().toLowerCase()).filter((keyword) => keyword.length > 0);
}
/**
* Gets folder name from cache instead of making API call.
* @returns {string} The folder name or 'Unknown' if not found.
* @private
*/
#getFolderName() {
return this.#folders.get(this.#bookmark.parentId.toString()) || 'Unknown';
}
/**
* Builds a bookmark entity for Favbox.
* @returns {Promise} A promise that resolves to the bookmark entity object.
*/
async getFavboxBookmark() {
console.warn(this.getImage());
const entity = {
id: this.#bookmark.id,
folderId: this.#bookmark.parentId,
folderName: this.#getFolderName(),
title: extractTitle(this.#bookmark.title),
description: this.getDescription(),
favicon: this.getFavicon(),
image: this.getImage(),
domain: this.getDomain(),
keywords: this.getKeywords(),
url: this.#bookmark.url,
tags: extractTags(this.#bookmark.title),
pinned: 0,
notes: '',
httpStatus: this.#httpResponse.httpStatus,
dateAdded: this.#bookmark.dateAdded,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return entity;
}
}
================================================
FILE: src/services/browserBookmarks.js
================================================
/**
* Counts the total number of bookmarks.
* @returns {Promise}
*/
export async function getBookmarksCount() {
const tree = await browser.bookmarks.getTree();
let count = 0;
const countBookmarks = (nodes) => {
nodes.forEach((node) => {
if (node.url) {
count += 1;
}
if (node.children) {
countBookmarks(node.children);
}
});
};
countBookmarks(tree);
return count;
}
/**
* Recursively collects all bookmarks from the given node.
* @param {object} node - The bookmark node.
* @returns {Array<{id: string, url: string}>}
*/
export function getBookmarksFromNode(node) {
if (!node) {
return [];
}
const items = [];
if (node.url) {
items.push({ id: node.id, url: node.url });
}
if (node.children) {
for (const child of node.children) {
items.push(...getBookmarksFromNode(child));
}
}
return items;
}
/**
* Retrieves the tree of all folders with bookmark counts.
* @returns {Promise>}
*/
export async function getFolderTree() {
const tree = await browser.bookmarks.getTree();
const buildFolders = (nodes) => nodes
.filter((node) => node.children && !node.url)
.map((node) => {
const children = buildFolders(node.children);
const ownCount = node.children.filter((n) => n.url).length;
const childrenCount = children.reduce((sum, c) => sum + c.count, 0);
return {
id: node.id,
label: node.title,
count: ownCount + childrenCount,
...(children.length > 0 && { children }),
};
});
return buildFolders(tree[0].children);
}
/**
* Retrieves all browser bookmarks.
* @yields {browser.bookmarks.BookmarkTreeNode}
*/
export async function* getBookmarksIterator() {
const bookmarksTree = await browser.bookmarks.getTree();
function* processNode(node) {
if (node.url) {
yield node;
}
if (node.children) {
for (const child of node.children) {
yield* processNode(child);
}
}
}
for (const rootNode of bookmarksTree) {
yield* processNode(rootNode);
}
}
/**
* @returns {Promise>}
*/
export async function getFoldersMap() {
const foldersMap = new Map();
const traverseTree = (nodes) => {
for (const node of nodes) {
if (node.children) {
foldersMap.set(node.id, node.title);
traverseTree(node.children);
}
}
};
const tree = await browser.bookmarks.getTree();
traverseTree(tree);
return foldersMap;
}
================================================
FILE: src/services/hash.js
================================================
export default function hashCode(...str) {
if (!str.length || !str.every((s) => typeof s === 'string')) {
return '0';
}
const s = str.map((v) => v.trim()).filter((v) => v).join('');
if (!s) {
return '0';
}
let h = 0;
for (let i = 0; i < s.length; i++) {
h = Math.imul(31, h) + s.charCodeAt(i) | 0;
}
return Math.abs(h).toString();
}
================================================
FILE: src/services/httpClient.js
================================================
import { HTTP_STATUS } from '@/constants/httpStatus';
/**
* Makes an HTTP GET request with a timeout.
* @param {string} url - The URL to fetch.
* @param {number} [timeout] - Timeout in milliseconds (default: 20000).
* @returns {Promise<{html: string|null, httpStatus: number}>}
*/
export async function fetchUrl(url, timeout = 20000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
method: 'GET',
mode: 'cors',
redirect: 'follow',
signal: controller.signal,
});
const text = await response.text();
return {
html: response.ok ? text : null,
httpStatus: response.status,
};
} catch (e) {
const errorCode = e.name === 'AbortError' ? HTTP_STATUS.REQUEST_TIMEOUT : HTTP_STATUS.UNKNOWN_ERROR;
return {
httpStatus: errorCode,
html: null,
};
} finally {
clearTimeout(id);
}
}
/**
* Makes a HEAD HTTP request with a timeout.
* @param {string} url - The URL to make HEAD request to.
* @param {number} [timeout] - Timeout in milliseconds (default: 20000).
* @returns {Promise} The HTTP status code or error code (REQUEST_TIMEOUT, UNKNOWN_ERROR).
*/
export async function fetchHead(url, timeout = 20000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
method: 'HEAD',
mode: 'cors',
redirect: 'follow',
signal: controller.signal,
});
return response.status;
} catch (e) {
const errorCode = e.name === 'AbortError' ? HTTP_STATUS.REQUEST_TIMEOUT : HTTP_STATUS.UNKNOWN_ERROR;
return errorCode;
} finally {
clearTimeout(id);
}
}
================================================
FILE: src/services/tags.js
================================================
/**
* Joins a title and tags into a single string.
* @param {string} title
* @param {Array} tags
* @returns {string}
*/
export function joinTitleAndTags(title, tags = []) {
const filteredTags = (tags || []).filter(Boolean);
if (filteredTags.length === 0) {
return title || '';
}
return `${title || ''} ${String.fromCodePoint(0x1f3f7)} ${filteredTags.map((tag) => `#${tag}`).join(' ')}`;
}
/**
* Extracts the title from a string that may contain tags.
* @param {string} string
* @returns {string} The extracted title.
*/
export function extractTitle(string) {
if (!string) return '';
return string.split(String.fromCodePoint(0x1f3f7))[0]?.trim() || '';
}
/**
* Extracts tags from a string.
* @param {string} string
* @returns {Array} An array of extracted tags.
*/
export function extractTags(string) {
if (!string) return [];
const parts = string.split(String.fromCodePoint(0x1f3f7)).map((part) => part.trim());
if (parts.length < 2 || parts[1].length === 0) {
return [];
}
return parts[1]
.split(/(?=#)/)
.map((tag) => tag.trim().replace(/^#/, ''))
.filter((tag) => tag && tag.length > 0 && !/^\uFE0F+$/.test(tag));
}
================================================
FILE: src/storage/attribute.js
================================================
import hashCode from '@/services/hash';
import useConnection from './idb/connection';
export default class AttributeStorage {
async search(includes, sortColumn = 'count', sortDirection = 'desc', term = '', skip = 0, limit = 200) {
const connection = await useConnection();
const whereConditions = {};
const keys = Object.entries(includes).reduce((acc, [key, value]) => {
if (value === true) {
acc.push(key);
}
return acc;
}, []);
if (keys.length === 0) {
return [];
}
Object.assign(whereConditions, { key: { in: keys } });
Object.assign(whereConditions, term ? { value: { like: `%${term}%` } } : {});
return connection.select({
from: 'attributes',
where: Object.keys(whereConditions).length === 0 ? null : whereConditions,
// distinct: true,
skip,
limit,
order: {
by: sortColumn,
type: sortDirection,
},
});
}
async filterByKeyAndValue(key, value, skip, limit = 50) {
const connection = await useConnection();
const whereConditions = [{ key }];
if (value) {
whereConditions.push({
value: { like: `%${value}%` },
});
}
return connection.select({
from: 'attributes',
distinct: true,
limit,
skip,
where: whereConditions,
order: {
by: 'value',
type: 'asc',
},
});
}
getAttributesFromBookmark(bookmark) {
const { domain = '', tags = [], keywords = [] } = bookmark;
const isValid = (v) => typeof v === 'string' && v.trim().length > 0;
return [
isValid(domain) && { key: 'domain', value: domain.trim(), id: hashCode('domain', domain.trim()) },
...tags.filter(isValid).map((tag) => ({ key: 'tag', value: tag.trim(), id: hashCode('tag', tag.trim()) })),
...keywords.filter(isValid).map((keyword) => ({ key: 'keyword', value: keyword.trim(), id: hashCode('keyword', keyword.trim()) })),
].filter(Boolean);
}
async create(bookmark) {
const connection = await useConnection();
const allAttributes = this.getAttributesFromBookmark(bookmark);
if (allAttributes.length === 0) return;
const existing = await connection.select({
from: 'attributes',
where: { id: { in: allAttributes.map((attr) => attr.id) } },
});
const existingMap = new Map(existing.map((r) => [r.id, r.count || 0]));
const updatedAttributes = allAttributes.map((attr) => ({
...attr,
count: (existingMap.get(attr.id) || 0) + 1,
}));
await connection.insert({
into: 'attributes',
upsert: true,
values: updatedAttributes,
skipDataCheck: true,
});
}
async remove(bookmark) {
console.log('AttributeStorage.remove', bookmark);
const connection = await useConnection();
const allAttributes = this.getAttributesFromBookmark(bookmark);
if (allAttributes.length === 0) return;
const ids = allAttributes.map((attr) => attr.id);
console.log(ids);
const existing = await connection.select({
from: 'attributes',
where: { id: { in: ids } },
});
const toDelete = [];
const toUpdate = [];
existing.forEach((record) => {
const newCount = (record.count || 0) - 1;
if (newCount <= 0) {
toDelete.push(record.id);
} else {
toUpdate.push({
...record,
count: newCount,
});
}
});
if (toDelete.length > 0) {
await connection.remove({
from: 'attributes',
where: { id: { in: toDelete } },
});
}
if (toUpdate.length > 0) {
await connection.insert({
into: 'attributes',
upsert: true,
values: toUpdate,
});
}
}
/**
* Update attributes when bookmark changes.
* Removes old attributes and adds new ones incrementally.
* @param {object} newBookmark - Updated bookmark
* @param {object} oldBookmark - Previous bookmark state
*/
async update(newBookmark, oldBookmark) {
if (oldBookmark) {
await this.remove(oldBookmark);
}
await this.create(newBookmark);
}
/**
* Refresh attributes from aggregated data.
* @param {Array} domains - Array of {field: 'domain', value: string, count: number}
* @param {Array} tags - Array of {field: 'tags', value: string, count: number}
* @param {Array} keywords - Array of {field: 'keywords', value: string, count: number}
* @param {boolean} truncate - Whether to clear existing attributes before inserting
*/
async refreshFromAggregated(domains = [], tags = [], keywords = [], truncate = true) {
const connection = await useConnection();
const toAttribute = (key, { value, count }) => ({
key,
value: String(value).trim(),
id: hashCode(key, String(value).trim()),
count,
});
const attributes = [
...domains.map((r) => toAttribute('domain', r)),
...tags.map((r) => toAttribute('tag', r)),
...keywords.map((r) => toAttribute('keyword', r)),
];
if (truncate) {
await connection.clear('attributes');
}
if (attributes.length > 0) {
await connection.insert({
into: 'attributes',
values: attributes,
validation: false,
skipDataCheck: true,
});
}
return attributes;
}
async clear() {
const connection = await useConnection();
await connection.clear('attributes');
}
async saveMany(attributes) {
if (!attributes.length) return;
const connection = await useConnection();
await connection.insert({
into: 'attributes',
values: attributes,
validation: false,
skipDataCheck: true,
});
}
}
================================================
FILE: src/storage/bookmark.js
================================================
import useConnection from './idb/connection';
export default class BookmarkStorage {
async createMany(data) {
const connection = await useConnection();
const result = await connection.insert({
into: 'bookmarks',
values: data,
validation: false,
skipDataCheck: true,
ignore: true,
});
return result;
}
async findAfterId(id, limit) {
const connection = await useConnection();
const query = {
from: 'bookmarks',
limit,
order: { by: 'id', type: 'asc' },
where: id ? { id: { '>': id } } : null,
};
return connection.select(query);
}
async search(query, skip = 0, limit = 50, sortDirection = 'desc') {
const connection = await useConnection();
const queryParams = {};
const whereConditions = [];
query.forEach(({ key, value }) => {
(queryParams[key] ??= []).push(value);
});
const conditions = [
{ key: 'folder', condition: { folderId: { in: queryParams.folder } } },
{ key: 'tag', condition: { tags: { in: queryParams.tag } } },
{ key: 'domain', condition: { domain: { in: queryParams.domain } } },
{ key: 'keyword', condition: { keywords: { in: queryParams.keyword } } },
{ key: 'id', condition: { id: { in: queryParams.id } } },
];
conditions.forEach(({ key, condition }) => {
if (queryParams[key]) {
whereConditions.push(condition);
}
});
if (queryParams?.term) {
const [term] = queryParams.term;
const regexPattern = term.split(/\s+/).map((word) => `(?=.*${word})`).join('');
const regex = new RegExp(`^${regexPattern}.*$`, 'i');
whereConditions.push({
title: { regex },
or: {
description: { regex },
or: {
url: { regex },
or: {
domain: { like: `%${term}%` },
or: {
keywords: { regex },
},
},
},
},
});
}
if (queryParams?.dateAdded?.[0]) {
const [startStr, endStr] = queryParams.dateAdded[0].split('~');
const low = new Date(startStr).setHours(0, 0, 0, 0);
const high = new Date(endStr).setHours(23, 59, 59, 999);
whereConditions.push({
dateAdded: { '-': { low, high } },
});
}
return connection.select({
from: 'bookmarks',
distinct: true,
limit,
skip,
order: {
by: 'dateAdded',
type: sortDirection,
},
where: whereConditions.length === 0 ? null : whereConditions,
});
}
async total() {
const connection = await useConnection();
return connection.count({
from: 'bookmarks',
});
}
async create(entity) {
const connection = await useConnection();
return connection.insert({
into: 'bookmarks',
values: [entity],
});
}
async updateHttpStatusById(id, status) {
const connection = await useConnection();
return connection.update({
in: 'bookmarks',
set: {
httpStatus: parseInt(status, 10),
updatedAt: new Date().toISOString(),
},
where: {
id,
},
});
}
async setOK() {
const connection = await useConnection();
return connection.update({
in: 'bookmarks',
set: { httpStatus: 200 },
});
}
async findPinned(skip = 0, limit = 50, term = '') {
const connection = await useConnection();
const whereConditions = [{ pinned: 1 }];
if (term) {
const regexPattern = term.split(/\s+/).map((word) => `(?=.*${word})`).join('');
const regex = new RegExp(`^${regexPattern}.*$`, 'i');
whereConditions.push({
notes: { regex },
or: {
title: { regex },
or: {
description: { regex },
or: {
domain: { like: `%${term}%` },
},
},
},
});
}
return connection.select({
from: 'bookmarks',
limit,
skip,
order: {
by: 'updatedAt',
type: 'desc',
},
where: whereConditions,
});
}
async updatePinStatusById(id, status) {
const connection = await useConnection();
return connection.update({
in: 'bookmarks',
set: {
pinned: parseInt(status, 10),
updatedAt: new Date().toISOString(),
},
where: {
id,
},
});
}
async update(id, data) {
const connection = await useConnection();
return connection.update({
in: 'bookmarks',
set: data,
where: {
id,
},
});
}
async removeByIds(ids) {
const connection = await useConnection();
const result = await connection.remove({
from: 'bookmarks',
where: {
id: {
in: ids,
},
},
});
return result;
}
async removeById(id) {
const connection = await useConnection();
return connection.remove({
from: 'bookmarks',
where: { id },
});
}
async getIds(ids) {
const connection = await useConnection();
const response = await connection.select({
from: 'bookmarks',
where: {
id: {
in: ids,
},
},
});
return response.map((i) => i.id);
}
async getByFolderId(folderId) {
const connection = await useConnection();
const response = await connection.select({
from: 'bookmarks',
limit: 1,
where: {
folderId,
},
});
return response.length === 1 ? response.shift() : null;
}
async updateBookmarksFolderName(folderId, folderName) {
const connection = await useConnection();
return connection.update({
in: 'bookmarks',
set: {
folderName,
updatedAt: new Date().toISOString(),
},
where: {
folderId,
},
});
}
async getById(id) {
const connection = await useConnection();
const response = await connection.select({
from: 'bookmarks',
limit: 1,
where: {
id,
},
});
return response.length === 1 ? response.shift() : null;
}
async getByUrl(url) {
const connection = await useConnection();
const response = await connection.select({
from: 'bookmarks',
limit: 1,
where: {
url: String(url),
},
});
return response.length === 1 ? response.shift() : null;
}
async getTags() {
const connection = await useConnection();
const response = await connection.select({
from: 'bookmarks',
flatten: ['tags'],
groupBy: 'tags',
order: {
by: 'tags',
type: 'asc',
},
});
return response.map((item) => item.tags);
}
async updateStatusByIds(status, ids) {
const connection = await useConnection();
return connection.update({
in: 'bookmarks',
set: {
httpStatus: status,
},
where: {
id: {
in: ids,
},
},
});
}
async updateNotesById(id, notes) {
const connection = await useConnection();
return connection.update({
in: 'bookmarks',
set: {
notes,
updatedAt: new Date().toISOString(),
},
where: {
id,
},
});
}
async updateImageById(id, image) {
const connection = await useConnection();
return connection.update({
in: 'bookmarks',
set: {
image,
},
where: {
id,
},
});
}
async findByHttpStatus(statuses, skip = 0, limit = 50) {
const connection = await useConnection();
return connection.select({
from: 'bookmarks',
limit,
skip,
order: {
by: 'id',
type: 'desc',
},
where: {
httpStatus: {
in: statuses,
},
},
});
}
async getTotalByHttpStatus(statuses) {
const connection = await useConnection();
return connection.count({
from: 'bookmarks',
where: {
httpStatus: {
in: statuses,
},
},
});
}
async getAllIds() {
const connection = await useConnection();
const response = await connection.select({
from: 'bookmarks',
columns: ['id'],
});
return response.map((i) => i.id);
}
async getDuplicatesGrouped(skip = 0, limit = 50) {
const connection = await useConnection();
// Get all URLs with the number of duplicates
const groupedResults = await connection.select({
from: 'bookmarks',
groupBy: 'url',
aggregate: {
count: ['id'],
},
});
// Filter only groups with duplicates (2+ bookmarks)
const duplicateGroups = groupedResults.filter((group) => group['count(id)'] > 1);
// Sort by url (alphabetically)
duplicateGroups.sort((a, b) => String(a.url).localeCompare(String(b.url)));
// Apply pagination
const paginatedGroups = duplicateGroups.slice(skip, skip + limit);
// Get all bookmarks for the current page in one query
const urls = paginatedGroups.map((group) => group.url);
const allBookmarks = await connection.select({
from: 'bookmarks',
where: { url: { in: urls } },
order: {
by: 'dateAdded',
type: 'desc',
},
});
// Group bookmarks by URL
const bookmarksByUrl = Object.groupBy(allBookmarks, (b) => b.url);
// Form the result
const groupsWithDetails = paginatedGroups.map((group) => {
const bookmarks = bookmarksByUrl[group.url] || [];
return {
url: group.url,
bookmarks,
count: group['count(id)'],
firstAdded: bookmarks[bookmarks.length - 1], // Oldest
lastAdded: bookmarks[0], // Newest
};
});
return {
groups: groupsWithDetails,
total: duplicateGroups.length,
hasMore: skip + limit < duplicateGroups.length,
};
}
async aggregateByField(field, flatten = false) {
const connection = await useConnection();
const query = {
from: 'bookmarks',
groupBy: field,
aggregate: { count: ['id'] },
};
if (flatten) query.flatten = [field];
const rows = await connection.select(query);
return rows
.filter((r) => r[field])
.map((r) => ({ field, value: r[field], count: r['count(id)'] }));
}
async aggregateDomains() {
return this.aggregateByField('domain');
}
async aggregateTags() {
return this.aggregateByField('tags', true);
}
async aggregateKeywords() {
return this.aggregateByField('keywords', true);
}
}
================================================
FILE: src/storage/idb/connection.js
================================================
/* eslint-disable import/extensions */
/* eslint-disable import/no-unresolved */
/* eslint-disable new-cap */
import { Connection, DATA_TYPE } from 'jsstore';
import workerInjector from 'jsstore/dist/worker_injector';
import jsstoreWorker from 'jsstore/dist/jsstore.worker.min.js?worker';
let connection = null;
let isDbInitialized = false;
const createConnection = () => {
if (typeof Worker === 'undefined') {
connection = new Connection();
connection.addPlugin(workerInjector);
console.warn('Web Worker is not supported.');
} else {
console.warn('Web Worker is supported.');
connection = new Connection(new jsstoreWorker());
}
if (import.meta.env.DEV) {
console.warn('DEV MODE');
connection.logStatus = true;
}
};
// using string for primary key to save compatible between Firefox and Chrome
const getDb = () => {
const tblBookmarks = {
name: 'bookmarks',
columns: {
id: {
primaryKey: true,
autoIncrement: false,
dataType: DATA_TYPE.String,
},
folderId: {
dataType: DATA_TYPE.String,
enableSearch: true,
notNull: false,
},
folderName: {
dataType: DATA_TYPE.String,
enableSearch: true,
},
title: {
notNull: true,
dataType: DATA_TYPE.String,
enableSearch: true,
},
description: {
dataType: DATA_TYPE.String,
enableSearch: true,
},
domain: {
dataType: DATA_TYPE.String,
enableSearch: true,
},
url: {
dataType: DATA_TYPE.String,
enableSearch: true,
},
favicon: {
dataType: DATA_TYPE.String,
enableSearch: false,
},
keywords: {
dataType: DATA_TYPE.Array,
multiEntry: true,
default: [],
enableSearch: true,
},
image: {
dataType: DATA_TYPE.String,
enableSearch: false,
},
tags: {
dataType: DATA_TYPE.Array,
multiEntry: true,
default: [],
enableSearch: true,
},
pinned: {
notNull: true,
dataType: DATA_TYPE.Number,
default: 0,
},
notes: {
dataType: DATA_TYPE.String,
notNull: false,
},
httpStatus: {
notNull: true,
dataType: DATA_TYPE.Number,
default: 200,
},
createdAt: {
dataType: DATA_TYPE.String,
notNull: true,
enableSearch: true,
},
updatedAt: {
dataType: DATA_TYPE.String,
notNull: true,
enableSearch: true,
},
dateAdded: {
notNull: true,
dataType: DATA_TYPE.Number,
enableSearch: true,
},
},
};
const tblAttributes = {
name: 'attributes',
columns: {
id: {
primaryKey: true,
autoIncrement: false,
dataType: DATA_TYPE.String,
},
key: {
notNull: true,
dataType: DATA_TYPE.String,
enableSearch: true,
},
value: {
notNull: true,
dataType: DATA_TYPE.String,
enableSearch: true,
},
count: {
notNull: true,
dataType: DATA_TYPE.Number,
enableSearch: true,
},
},
};
const database = {
name: 'favbox_database_v2',
tables: [tblBookmarks, tblAttributes],
};
return database;
};
const useConnection = async () => {
if (!connection) {
createConnection();
}
if (!isDbInitialized) {
await connection.initDb(getDb());
isDbInitialized = true;
}
return connection;
};
export default useConnection;
================================================
FILE: tests/integration/fetch.spec.js
================================================
import { describe, expect, it } from 'vitest';
import { fetchUrl, fetchHead } from '@/services/httpClient';
import { HTTP_STATUS } from '@/constants/httpStatus';
describe('HTTP Client', () => {
it('fetch', async () => {
const result = await fetchUrl('https://jsonplaceholder.typicode.com/posts/1');
expect(result.httpStatus).toEqual(HTTP_STATUS.OK);
expect(result.html).toBeTypeOf('string');
});
it('fetch with timeout', async () => {
const result = await fetchUrl('https://jsonplaceholder.typicode.com/posts/1', 1);
expect(result.httpStatus).toEqual(HTTP_STATUS.REQUEST_TIMEOUT);
});
it('head', async () => {
const result = await fetchHead('https://jsonplaceholder.typicode.com/posts/1');
expect(result).toEqual(HTTP_STATUS.OK);
});
it('head with timeout', async () => {
const result = await fetchHead('https://jsonplaceholder.typicode.com/posts/1', 1);
expect(result).toEqual(HTTP_STATUS.REQUEST_TIMEOUT);
});
it('head with bad request', async () => {
const result = await fetchHead('https://jsonplaceholder.typicode.com/posts/1/123');
expect(result).toEqual(HTTP_STATUS.NOT_FOUND);
});
});
================================================
FILE: tests/unit/browserBookmarks.spec.js
================================================
import {
getBookmarksCount,
getBookmarksFromNode,
getFolderTree,
getFoldersMap,
getBookmarksIterator,
} from '@/services/browserBookmarks';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import browser from 'webextension-polyfill';
vi.mock('webextension-polyfill', () => ({
default: {
bookmarks: {
getTree: vi.fn(),
},
},
}));
const mockGetTree = vi.mocked(browser.bookmarks.getTree);
describe('browserBookmarks', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetTree.mockClear();
});
describe('getBookmarksCount', () => {
it('should count bookmarks in tree', async () => {
const mockTree = [
{
id: '0',
children: [
{
id: '1',
title: 'Folder 1',
children: [
{ id: '2', title: 'Bookmark 1', url: 'https://example.com' },
{ id: '3', title: 'Bookmark 2', url: 'https://google.com' },
],
},
{
id: '4',
title: 'Folder 2',
children: [
{ id: '5', title: 'Bookmark 3', url: 'https://github.com' },
],
},
],
},
];
mockGetTree.mockResolvedValue(mockTree);
const count = await getBookmarksCount();
expect(count).toBe(3);
});
it('should return 0 for empty tree', async () => {
mockGetTree.mockResolvedValue([{ id: '0', children: [] }]);
const count = await getBookmarksCount();
expect(count).toBe(0);
});
it('should handle nested folders', async () => {
const mockTree = [
{
id: '0',
children: [
{
id: '1',
title: 'Folder 1',
children: [
{
id: '2',
title: 'Subfolder',
children: [
{ id: '3', title: 'Bookmark 1', url: 'https://example.com' },
],
},
],
},
],
},
];
mockGetTree.mockResolvedValue(mockTree);
const count = await getBookmarksCount();
expect(count).toBe(1);
});
});
describe('getBookmarksFromNode', () => {
it('should extract bookmarks from node', () => {
const node = {
id: '1',
title: 'Folder',
children: [
{ id: '2', title: 'Bookmark 1', url: 'https://example.com' },
{ id: '3', title: 'Bookmark 2', url: 'https://google.com' },
],
};
const bookmarks = getBookmarksFromNode(node);
expect(bookmarks).toHaveLength(2);
expect(bookmarks[0]).toEqual({ id: '2', url: 'https://example.com' });
expect(bookmarks[1]).toEqual({ id: '3', url: 'https://google.com' });
});
it('should handle node with url (bookmark itself)', () => {
const node = {
id: '1',
title: 'Bookmark',
url: 'https://example.com',
};
const bookmarks = getBookmarksFromNode(node);
expect(bookmarks).toHaveLength(1);
expect(bookmarks[0]).toEqual({ id: '1', url: 'https://example.com' });
});
it('should return empty array for null node', () => {
expect(getBookmarksFromNode(null)).toEqual([]);
});
it('should return empty array for undefined node', () => {
expect(getBookmarksFromNode(undefined)).toEqual([]);
});
it('should handle nested folders', () => {
const node = {
id: '1',
title: 'Folder',
children: [
{
id: '2',
title: 'Subfolder',
children: [
{ id: '3', title: 'Bookmark 1', url: 'https://example.com' },
],
},
{ id: '4', title: 'Bookmark 2', url: 'https://google.com' },
],
};
const bookmarks = getBookmarksFromNode(node);
expect(bookmarks).toHaveLength(2);
});
it('should ignore folders without url', () => {
const node = {
id: '1',
title: 'Folder',
children: [
{ id: '2', title: 'Subfolder', children: [] },
{ id: '3', title: 'Bookmark', url: 'https://example.com' },
],
};
const bookmarks = getBookmarksFromNode(node);
expect(bookmarks).toHaveLength(1);
expect(bookmarks[0].id).toBe('3');
});
});
describe('getFolderTree', () => {
it('should build folder tree with counts', async () => {
const mockTree = [
{
id: '0',
children: [
{
id: '1',
title: 'Folder 1',
children: [
{ id: '2', title: 'Bookmark 1', url: 'https://example.com' },
{ id: '3', title: 'Bookmark 2', url: 'https://google.com' },
],
},
{
id: '4',
title: 'Folder 2',
children: [
{ id: '5', title: 'Bookmark 3', url: 'https://github.com' },
],
},
],
},
];
mockGetTree.mockResolvedValue(mockTree);
const tree = await getFolderTree();
expect(tree).toHaveLength(2);
expect(tree[0]).toMatchObject({
id: '1',
label: 'Folder 1',
count: 2,
});
expect(tree[1]).toMatchObject({
id: '4',
label: 'Folder 2',
count: 1,
});
});
it('should handle nested folders with correct counts', async () => {
const mockTree = [
{
id: '0',
children: [
{
id: '1',
title: 'Folder 1',
children: [
{ id: '2', title: 'Bookmark 1', url: 'https://example.com' },
{
id: '3',
title: 'Subfolder',
children: [
{ id: '4', title: 'Bookmark 2', url: 'https://google.com' },
{ id: '5', title: 'Bookmark 3', url: 'https://github.com' },
],
},
],
},
],
},
];
mockGetTree.mockResolvedValue(mockTree);
const tree = await getFolderTree();
expect(tree).toHaveLength(1);
expect(tree[0].count).toBe(3); // 1 direct + 2 from subfolder
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children[0].count).toBe(2);
});
it('should filter out bookmarks (only folders)', async () => {
const mockTree = [
{
id: '0',
children: [
{ id: '1', title: 'Bookmark 1', url: 'https://example.com' },
{
id: '2',
title: 'Folder',
children: [
{ id: '3', title: 'Bookmark 2', url: 'https://google.com' },
],
},
],
},
];
mockGetTree.mockResolvedValue(mockTree);
const tree = await getFolderTree();
expect(tree).toHaveLength(1);
expect(tree[0].id).toBe('2');
});
it('should not include children property when folder has no subfolders', async () => {
const mockTree = [
{
id: '0',
children: [
{
id: '1',
title: 'Folder 1',
children: [
{ id: '2', title: 'Bookmark 1', url: 'https://example.com' },
],
},
],
},
];
mockGetTree.mockResolvedValue(mockTree);
const tree = await getFolderTree();
expect(tree[0].children).toBeUndefined();
});
});
describe('getFoldersMap', () => {
it('should create map of folder ids to titles', async () => {
const mockTree = [
{
id: '0',
children: [
{
id: '1',
title: 'Folder 1',
children: [
{ id: '2', title: 'Bookmark', url: 'https://example.com' },
],
},
{
id: '3',
title: 'Folder 2',
children: [],
},
],
},
];
mockGetTree.mockResolvedValue(mockTree);
const map = await getFoldersMap();
expect(map).toBeInstanceOf(Map);
expect(map.get('1')).toBe('Folder 1');
expect(map.get('3')).toBe('Folder 2');
});
it('should handle nested folders', async () => {
const mockTree = [
{
id: '0',
children: [
{
id: '1',
title: 'Folder 1',
children: [
{
id: '2',
title: 'Subfolder',
children: [],
},
],
},
],
},
];
mockGetTree.mockResolvedValue(mockTree);
const map = await getFoldersMap();
expect(map.get('1')).toBe('Folder 1');
expect(map.get('2')).toBe('Subfolder');
});
});
describe('getBookmarksIterator', () => {
it('should iterate over all bookmarks', async () => {
const mockTree = [
{
id: '0',
children: [
{
id: '1',
title: 'Folder 1',
children: [
{ id: '2', title: 'Bookmark 1', url: 'https://example.com' },
{ id: '3', title: 'Bookmark 2', url: 'https://google.com' },
],
},
{ id: '4', title: 'Bookmark 3', url: 'https://github.com' },
],
},
];
mockGetTree.mockResolvedValue(mockTree);
const bookmarks = [];
for await (const bookmark of getBookmarksIterator()) {
bookmarks.push(bookmark);
}
expect(bookmarks).toHaveLength(3);
expect(bookmarks[0].id).toBe('2');
expect(bookmarks[1].id).toBe('3');
expect(bookmarks[2].id).toBe('4');
});
it('should handle empty tree', async () => {
mockGetTree.mockResolvedValue([{ id: '0', children: [] }]);
const bookmarks = [];
for await (const bookmark of getBookmarksIterator()) {
bookmarks.push(bookmark);
}
expect(bookmarks).toHaveLength(0);
});
it('should handle nested folders', async () => {
const mockTree = [
{
id: '0',
children: [
{
id: '1',
title: 'Folder',
children: [
{
id: '2',
title: 'Subfolder',
children: [
{ id: '3', title: 'Bookmark', url: 'https://example.com' },
],
},
],
},
],
},
];
mockGetTree.mockResolvedValue(mockTree);
const bookmarks = [];
for await (const bookmark of getBookmarksIterator()) {
bookmarks.push(bookmark);
}
expect(bookmarks).toHaveLength(1);
expect(bookmarks[0].id).toBe('3');
});
});
});
================================================
FILE: tests/unit/hash.spec.js
================================================
import { describe, expect, it } from 'vitest';
import hashCode from '@/services/hash';
describe('hashCode', () => {
it('should generate hash for single string', () => {
const hash = hashCode('test');
expect(hash).toBeTypeOf('string');
expect(hash).not.toBe('0');
});
it('should generate same hash for same input', () => {
const hash1 = hashCode('domain', 'example.com');
const hash2 = hashCode('domain', 'example.com');
expect(hash1).toBe(hash2);
});
it('should generate different hash for different inputs', () => {
const hash1 = hashCode('domain', 'example.com');
const hash2 = hashCode('domain', 'google.com');
expect(hash1).not.toBe(hash2);
});
it('should generate different hash for different keys with same value', () => {
const hash1 = hashCode('domain', 'test');
const hash2 = hashCode('tag', 'test');
expect(hash1).not.toBe(hash2);
});
it('should concatenate multiple strings', () => {
const hash1 = hashCode('domain', 'example.com');
const hash2 = hashCode('domainexample.com');
expect(hash1).toBe(hash2);
});
it('should trim whitespace from strings', () => {
const hash1 = hashCode('domain', 'example.com');
const hash2 = hashCode('domain', ' example.com ');
expect(hash1).toBe(hash2);
});
it('should filter out empty strings', () => {
const hash1 = hashCode('domain', 'example.com');
const hash2 = hashCode('domain', '', 'example.com', '');
expect(hash1).toBe(hash2);
});
it('should return "0" for empty string', () => {
expect(hashCode('')).toBe('0');
});
it('should return "0" for array of empty strings', () => {
expect(hashCode('', '', '')).toBe('0');
});
it('should return "0" for no arguments', () => {
expect(hashCode()).toBe('0');
});
it('should return "0" for non-string arguments', () => {
expect(hashCode(123)).toBe('0');
expect(hashCode(null)).toBe('0');
expect(hashCode(undefined)).toBe('0');
expect(hashCode({})).toBe('0');
expect(hashCode([])).toBe('0');
});
it('should return "0" for mixed string and non-string arguments', () => {
expect(hashCode('test', 123)).toBe('0');
expect(hashCode('test', null)).toBe('0');
expect(hashCode('test', undefined)).toBe('0');
});
it('should handle special characters', () => {
const hash = hashCode('tag', 'test-tag_123');
expect(hash).toBeTypeOf('string');
expect(hash).not.toBe('0');
});
it('should handle unicode characters', () => {
const hash = hashCode('tag', 'тест');
expect(hash).toBeTypeOf('string');
expect(hash).not.toBe('0');
});
it('should always return positive number as string', () => {
const hash = hashCode('test');
expect(parseInt(hash, 10)).toBeGreaterThanOrEqual(0);
});
});
================================================
FILE: tests/unit/metadataParser.spec.js
================================================
import { describe, expect, it, beforeEach } from 'vitest';
import MetadataParser from '@/parser/metadata';
describe('MetadataParser', () => {
let mockBookmark;
let mockFolders;
beforeEach(() => {
mockBookmark = {
id: '123',
title: 'Test Bookmark',
url: 'https://example.com',
parentId: '1',
dateAdded: Date.now(),
};
mockFolders = new Map([
['1', 'Test Folder'],
['2', 'Another Folder'],
]);
});
describe('constructor', () => {
it('should create instance with all required parameters', () => {
const parser = new MetadataParser(mockBookmark, { html: '' }, mockFolders);
expect(parser).toBeInstanceOf(MetadataParser);
});
});
describe('getTitle', () => {
it('should return bookmark title when available', () => {
const parser = new MetadataParser({ title: 'Test Bookmark' }, { html: '' });
expect(parser.getTitle()).toBe('Test Bookmark');
});
it('should fallback to document title when bookmark title is empty', () => {
const html = `
Example Page Title
Main Heading
`;
const parser = new MetadataParser({ title: '' }, { html });
expect(parser.getTitle()).toBe('Example Page Title');
});
it('should fallback to og:title when document title is empty', () => {
const html = `
Main Heading
`;
const parser = new MetadataParser({ title: '' }, { html });
expect(parser.getTitle()).toBe('OG Title');
});
it('should fallback to h1 when meta titles are empty', () => {
const html = `
Main Heading
`;
const parser = new MetadataParser({ title: '' }, { html });
expect(parser.getTitle()).toBe('Main Heading');
});
it('should return empty string when no title is found', () => {
const html = '';
const parser = new MetadataParser({ title: '' }, { html });
expect(parser.getTitle()).toBe('');
});
});
describe('getDescription', () => {
it('should return first available description (og:description in this case)', () => {
const html = `
`;
const parser = new MetadataParser({}, { html });
expect(parser.getDescription()).toBe('OG Description');
});
it('should fallback to meta description when og:description is not available', () => {
const html = `
`;
const parser = new MetadataParser({}, { html });
expect(parser.getDescription()).toBe('This is a test description');
});
it('should return null when no description is found', () => {
const html = '';
const parser = new MetadataParser({}, { html });
expect(parser.getDescription()).toBeNull();
});
});
describe('getImage', () => {
it('should return og:image when available', () => {
const html = `
`;
const parser = new MetadataParser({ url: 'https://example.com' }, { html });
expect(parser.getImage()).toBe('https://example.com/image.jpg');
});
it('should fallback to page preview when og:image is not available', () => {
const html = `
`;
const parser = new MetadataParser({ url: 'https://example.com' }, { html });
expect(parser.getImage()).toBe('https://example.com/hero.jpg');
});
it('should return null when no image is found', () => {
const html = '';
const parser = new MetadataParser({}, { html });
expect(parser.getImage()).toBeNull();
});
it('should resolve relative URLs to absolute', () => {
const html = `
`;
const parser = new MetadataParser({ url: 'https://example.com' }, { html });
expect(parser.getImage()).toBe('https://example.com/relative-image.jpg');
});
it('should return YouTube thumbnail for youtube.com/watch URL', () => {
const html = '';
const parser = new MetadataParser({ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, { html });
expect(parser.getImage()).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg');
});
it('should return YouTube thumbnail for youtu.be URL', () => {
const html = '';
const parser = new MetadataParser({ url: 'https://youtu.be/dQw4w9WgXcQ' }, { html });
expect(parser.getImage()).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg');
});
it('should handle YouTube URL with additional parameters', () => {
const html = '';
const parser = new MetadataParser({ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s&feature=share' }, { html });
expect(parser.getImage()).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg');
});
});
describe('getDomain', () => {
it('should extract domain from URL', () => {
const parser = new MetadataParser({ url: 'https://example.com' }, { html: '' });
expect(parser.getDomain()).toBe('example.com');
});
it('should remove www prefix', () => {
const parser = new MetadataParser({ url: 'https://www.example.com' }, { html: '' });
expect(parser.getDomain()).toBe('example.com');
});
it('should handle URLs without protocol by adding https', () => {
const parser = new MetadataParser({ url: 'https://example.com' }, { html: '' });
expect(parser.getDomain()).toBe('example.com');
});
});
describe('getFavicon', () => {
it('should return favicon from link tag', () => {
const html = `
`;
const parser = new MetadataParser({ url: 'https://example.com' }, { html });
expect(parser.getFavicon()).toBe('https://example.com/favicon.ico');
});
it('should fallback to default favicon when not found', () => {
const html = '';
const parser = new MetadataParser({ url: 'https://example.com' }, { html });
expect(parser.getFavicon()).toBe('https://example.com/favicon.ico');
});
it('should prefer SVG favicon over regular favicon', () => {
const html = `
`;
const parser = new MetadataParser({ url: 'https://example.com' }, { html });
expect(parser.getFavicon()).toBe('https://example.com/favicon.svg');
});
});
describe('getKeywords', () => {
it('should extract keywords from meta tag', () => {
const html = `
`;
const parser = new MetadataParser({}, { html });
expect(parser.getKeywords()).toEqual(['test', 'example', 'bookmark']);
});
it('should return empty array when no keywords are found', () => {
const html = '';
const parser = new MetadataParser({}, { html });
expect(parser.getKeywords()).toEqual([]);
});
it('should handle empty keywords content', () => {
const html = `
`;
const parser = new MetadataParser({}, { html });
expect(parser.getKeywords()).toEqual([]);
});
});
describe('getUrl', () => {
it('should return bookmark URL', () => {
const parser = new MetadataParser({ url: 'https://example.com' }, { html: '' });
expect(parser.getUrl()).toBe('https://example.com');
});
});
describe('getFavboxBookmark', () => {
it('should return complete bookmark entity', async () => {
const html = `
`;
const parser = new MetadataParser(mockBookmark, { html }, mockFolders);
const entity = await parser.getFavboxBookmark();
expect(entity).toMatchObject({
id: '123',
folderId: '1',
folderName: 'Test Folder',
title: 'Test Bookmark',
description: 'OG Description',
favicon: 'https://example.com/favicon.ico',
image: 'https://example.com/image.jpg',
domain: 'example.com',
keywords: ['test', 'example', 'bookmark'],
url: 'https://example.com',
tags: [],
pinned: 0,
notes: '',
httpStatus: undefined,
dateAdded: mockBookmark.dateAdded,
});
expect(entity.createdAt).toBeDefined();
expect(entity.updatedAt).toBeDefined();
expect(new Date(entity.createdAt)).toBeInstanceOf(Date);
expect(new Date(entity.updatedAt)).toBeInstanceOf(Date);
});
it('should use real tag functions for title and tags processing', async () => {
const html = '';
const parser = new MetadataParser(mockBookmark, { html }, mockFolders);
const entity = await parser.getFavboxBookmark();
expect(entity.title).toBe('Test Bookmark');
expect(entity.tags).toEqual([]);
});
it('should handle missing folder name', async () => {
const html = '';
const emptyFolders = new Map();
const parser = new MetadataParser(mockBookmark, { html }, emptyFolders);
const entity = await parser.getFavboxBookmark();
expect(entity.folderName).toBe('Unknown');
});
it('should handle bookmark with tags in title', async () => {
const html = '';
const bookmarkWithTags = { ...mockBookmark, title: `Test Bookmark ${String.fromCodePoint(0x1f3f7)} #tag1 #tag2` };
const parser = new MetadataParser(bookmarkWithTags, { html }, mockFolders);
const entity = await parser.getFavboxBookmark();
expect(entity.title).toBe('Test Bookmark');
expect(entity.tags).toEqual(['tag1', 'tag2']);
});
it('should handle bookmark with tags but no separator', async () => {
const html = '';
const bookmarkWithTagsNoSeparator = { ...mockBookmark, title: 'Test Bookmark #tag1 #tag2' };
const parser = new MetadataParser(bookmarkWithTagsNoSeparator, { html }, mockFolders);
const entity = await parser.getFavboxBookmark();
expect(entity.title).toBe('Test Bookmark #tag1 #tag2');
expect(entity.tags).toEqual([]);
});
});
describe('error handling', () => {
it('should handle malformed HTML gracefully', () => {
const malformedHtml = 'Test {
const parser = new MetadataParser({}, { html: '' });
expect(parser).toBeInstanceOf(MetadataParser);
});
});
});
================================================
FILE: tests/unit/tagHelper.spec.js
================================================
import { describe, expect, it } from 'vitest';
import { joinTitleAndTags, extractTags, extractTitle } from '@/services/tags';
describe('TagHelper', () => {
it('should return title when tags array is empty', () => {
const title = 'Hello world';
expect(joinTitleAndTags(title, [])).toEqual(title);
});
it('should return title with tags', () => {
expect(joinTitleAndTags('Test', ['tag1'])).toEqual(
`Test ${String.fromCodePoint(0x1f3f7)} #tag1`,
);
});
it('should extract tags from string', () => {
const string = `🧪 PhpStorm Tips & Tricks ${String.fromCodePoint(
0x1f3f7,
)} #php #test`;
expect(extractTags(string)).toEqual(['php', 'test']);
});
it('should return empty array if string does not contain separator', () => {
const string = '🧪 PhpStorm Tips & Tricks #php #test';
expect(extractTags(string)).toEqual([]);
});
it('should return title without tags', () => {
const string = `Some bookmark title ${String.fromCodePoint(
0x1f3f7,
)} #php #js`;
expect(extractTitle(string)).toEqual('Some bookmark title');
});
it('should return string with tags containing spaces', () => {
expect(joinTitleAndTags('Hello world', ['test', 'some tag'])).toEqual(
`Hello world ${String.fromCodePoint(0x1f3f7)} #test #some tag`,
);
});
it('should extract tags with spaces from string', () => {
expect(
extractTags(
`string - test ${String.fromCodePoint(
0x1f3f7,
)} #hello world #qqq #test`,
),
).toEqual(['hello world', 'qqq', 'test']);
});
it('should return empty string when input is empty', () => {
expect(joinTitleAndTags('', [])).toEqual('');
});
it('should return empty array when extractTags receives empty string', () => {
expect(extractTags('')).toEqual([]);
});
it('should return empty array if string has no tags after separator', () => {
const string = `Test ${String.fromCodePoint(0x1f3f7)}`;
expect(extractTags(string)).toEqual([]);
});
it('should return correct title and empty tags if string only contains separator', () => {
const string = `Hello ${String.fromCodePoint(0x1f3f7)}`;
expect(extractTitle(string)).toEqual('Hello');
expect(extractTags(string)).toEqual([]);
});
it('should handle tags with special characters correctly', () => {
const string = `Test ${String.fromCodePoint(0x1f3f7)} #tag_one #tag-two #tag.three #tag@four`;
expect(extractTags(string)).toEqual(['tag_one', 'tag-two', 'tag.three', 'tag@four']);
});
it('should trim extra spaces around tags', () => {
const string = ` Test ${String.fromCodePoint(0x1f3f7)} #tag1 #tag2 `;
expect(extractTags(string)).toEqual(['tag1', 'tag2']);
});
it('should return title if tags array contains only empty strings', () => {
expect(joinTitleAndTags('Title', ['', ''])).toEqual('Title');
});
it('should filter out falsy values from tags array', () => {
expect(joinTitleAndTags('Title', ['tag1', '', 'tag2', null, undefined])).toEqual(
`Title ${String.fromCodePoint(0x1f3f7)} #tag1 #tag2`,
);
});
});
================================================
FILE: vite.config.firefox.js
================================================
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
import { crx } from '@crxjs/vite-plugin';
import Icons from 'unplugin-icons/vite';
import AutoImport from 'unplugin-auto-import/vite';
import manifest from './manifest.firefox.json';
export default defineConfig({
plugins: [
vue(),
crx({ browser: 'firefox', manifest }),
Icons({
autoInstall: true,
}),
AutoImport({
imports: [
{
'webextension-polyfill': [['*', 'browser']],
},
],
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
root: resolve(__dirname, 'src'),
publicDir: resolve(__dirname, 'public'),
build: {
outDir: resolve(__dirname, 'dist/firefox'),
rollupOptions: {
input: {
app: '/ext/browser/index.html',
},
},
minify: 'terser',
sourcemap: false,
// https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements
terserOptions: {
mangle: false,
compress: {
drop_console: false,
drop_debugger: false,
},
},
},
server: {
port: 5173,
strictPort: true,
hmr: {
port: 5173,
},
},
});
================================================
FILE: vite.config.js
================================================
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
import { crx } from '@crxjs/vite-plugin';
import Icons from 'unplugin-icons/vite';
import AutoImport from 'unplugin-auto-import/vite';
import tailwindcss from '@tailwindcss/vite';
import manifest from './manifest.chrome.json';
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
crx({ manifest }),
Icons({
autoInstall: true,
}),
AutoImport({
imports: [
{
'webextension-polyfill': [['default', 'browser']],
},
],
dts: false,
}),
],
optimizeDeps: {
include: ['webextension-polyfill', 'unplugin-icons', 'unplugin-auto-import'],
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
root: resolve(__dirname, 'src'),
publicDir: resolve(__dirname, 'public'),
build: {
outDir: resolve(__dirname, 'dist/chrome'),
rollupOptions: {
input: {
app: '/ext/browser/index.html',
},
},
minify: 'terser',
sourcemap: false,
// https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements
terserOptions: {
mangle: false,
compress: {
drop_console: true,
drop_debugger: true,
},
},
},
server: {
port: 5173,
strictPort: true,
hmr: {
port: 5173,
},
},
test: {
cache: false,
},
});