[
  {
    "path": ".eslintignore",
    "content": "node_modules/\ndist/\npublic/\n*.min.js\npackage-lock.json "
  },
  {
    "path": ".eslintrc.cjs",
    "content": "module.exports = {\n  env: {\n    browser: true,\n    es2021: true,\n    webextensions: true,\n    node: true,\n  },\n  extends: ['plugin:vue/recommended', 'airbnb-base', 'plugin:jsdoc/recommended', 'plugin:vuejs-accessibility/recommended'],\n  parserOptions: {\n    ecmaVersion: 'latest',\n    sourceType: 'module',\n  },\n  plugins: ['vue', 'import', 'jsdoc', 'vuejs-accessibility'],\n  rules: {\n    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',\n    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',\n    'max-len': 'off',\n    'vue/no-v-model-argument': 'off',\n    'no-plusplus': 'off',\n    'no-continue': 'off',\n    'class-methods-use-this': 'off',\n    'no-unused-expressions': 'off',\n    'object-curly-newline': 'off',\n    'no-new': 'off',\n    'no-bitwise': 'off',\n    'no-restricted-syntax': [\n      'error',\n      'ForInStatement',\n      'LabeledStatement',\n      'WithStatement',\n    ],\n    'no-use-before-define': ['error', { functions: false }],\n    'no-param-reassign': ['error', { props: false }],\n    'import/no-unresolved': ['error', { ignore: ['^~icons/', '^floating-vue', '^floating-vue/dist/style', '^floating-vue/style', '\\\\.css$', '\\\\.scss$', '\\\\.sass$'] }],\n    'import/extensions': ['error', { ignore: ['^~icons/', '^floating-vue', '^floating-vue/dist/style', '^floating-vue/style', '\\\\.css$', '\\\\.scss$', '\\\\.sass$'] }],\n    // JSDoc rules\n    'jsdoc/require-jsdoc': 'off',\n    'jsdoc/require-param-description': 'off',\n    'jsdoc/require-returns-description': 'off',\n    'jsdoc/require-param': 'off',\n    'jsdoc/require-returns': 'off',\n    'no-await-in-loop': 'off',\n    // Accessibility rules\n    'vuejs-accessibility/alt-text': 'error',\n    'vuejs-accessibility/anchor-has-content': 'error',\n    'vuejs-accessibility/aria-props': 'error',\n    'vuejs-accessibility/aria-unsupported-elements': 'error',\n    'vuejs-accessibility/click-events-have-key-events': 'error',\n    'vuejs-accessibility/heading-has-content': 'error',\n    'vuejs-accessibility/iframe-has-title': 'error',\n    'vuejs-accessibility/interactive-supports-focus': 'error',\n    'vuejs-accessibility/label-has-for': 'error',\n    'vuejs-accessibility/media-has-caption': 'warn',\n    'vuejs-accessibility/mouse-events-have-key-events': 'error',\n    'vuejs-accessibility/no-access-key': 'error',\n    'vuejs-accessibility/no-autofocus': 'error',\n    'vuejs-accessibility/no-distracting-elements': 'error',\n    'vuejs-accessibility/no-redundant-roles': 'error',\n    'vuejs-accessibility/role-has-required-aria-props': 'error',\n    'vuejs-accessibility/tabindex-no-positive': 'error',\n    'vuejs-accessibility/no-static-element-interactions': 'error',\n    'vuejs-accessibility/form-control-has-label': 'error',\n  },\n  settings: {\n    'import/resolver': {\n      alias: {\n        map: [['@', './src']],\n        extensions: ['.ts', '.js', '.jsx', '.tsx', '.json'],\n      },\n    },\n    'import/ignore': [\n    ],\n  },\n  ignorePatterns: ['vite.config.js', 'vite.config.firefox.js', 'src/ext/browser/app.js'],\n};\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.DS_Store\ndist\ndist-ssr\ncoverage\n*.local\n\n/cypress/videos/\n/cypress/screenshots/\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Magalyas Dmitry\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# FavBox\n\n<p align=\"center\">\n<a href=\"https://github.com/dd3v/favbox/issues\"><img src=\"https://img.shields.io/github/issues/dd3v/favbox\" alt=\"issues\"></a>\n<a href=\"https://github.com/dd3v/favbox\"><img src=\"https://img.shields.io/github/package-json/v/dd3v/favbox\" alt=\"ver\"></a>\n<a href=\"https://github.com/dd3v/favbox\"><img src=\"https://img.shields.io/badge/License-MIT-yellow.svg\" alt=\"license\"></a>\n<a href=\"https://github.com/dd3v/favbox\"><img src=\"https://img.shields.io/badge/Made%20With-Love-orange.svg\" alt=\"love\"></a>\n</p>\n\n<p align=\"center\">\n  <a href=\"app_demo.png\"><img src=\"app_demo.png\" alt=\"FavBox Light Theme\" width=\"48%\"></a>\n  <a href=\"app_demo_dark.png\"><img src=\"app_demo_dark.png\" alt=\"FavBox Dark Theme\" width=\"48%\"></a>\n</p>\n\n<p align=\"center\">\n<a href=\"https://chrome.google.com/webstore/detail/favbox/eangbddipcghohfjefjmfihcjgjnnemj\">\n<img src=\"https://img.shields.io/badge/Google%20Chrome-4285F4?style=for-the-badge&logo=GoogleChrome&logoColor=white\">\n</a>\n</p>\n\n\nFavBox 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.\n\nKey features:\n\n🔄 Syncs with your browser profile \\\n🔒 No third‑party data sharing. No ads. No tracking. \\\n🎨 Minimalist, clean UI\\\n🏷️ Tag support for easy organization\\\n🔍 Advanced search, sorting, and filtering by tags, domains, folders, and keywords\\\n🌁 Multiple display modes\\\n🌗 Light and dark themes\\\n🗑️ Detects broken and duplicate bookmarks\\\n⌨️ Hotkeys for quick search access\\\n🗒️ Local notes support\\\n❤️ Free and open source\n\n### Concept\n\n![image](concept.png) \n\n### Implementation\n\nFavBox 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.\n\n\nFavBox 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.\n\nFor example, if you have a bookmark titled `Google Chrome — Wikipedia`, to save tags across devices, extension appends them to the title like this:\n`Google Chrome — Wikipedia 🏷 #wiki #browser`\n\nThis way, your tags become available on other devices without using any cloud services — only through the standard Google Chrome profile sync.\n\n\n```\n├── public                 # Static assets (icons, etc.)\n│   └── icons\n├── src                    # Source code\n│   ├── assets             # Global styles\n│   ├── components         # Shared UI components\n│   │   └── app\n│   ├── composables        # Vue composables\n│   ├── constants          # Application constants\n│   ├── ext                # Browser extension\n│   │   ├── browser        # FavBox main app\n│   │   │   ├── components\n│   │   │   ├── layouts\n│   │   │   └── views\n│   │   ├── content        # Content scripts\n│   │   ├── popup          # Extension popup\n│   │   └── sw             # Service worker\n│   ├── parser             # HTML metadata parser\n│   ├── services           # Utility services (HTTP client, bookmarks API, tags, hash)\n│   └── storage            # IndexedDB storage\n│       └── idb\n└── tests\n    ├── integration\n    └── unit\n```\n\n### Permissions\n\n| Permission | Why needed |\n|------------|------------|\n| `bookmarks` | Read and manage bookmarks|\n| `activeTab` | Capture page screenshot for visual previews |\n| `tabs` | Get current tab info when saving bookmarks |\n| `storage` | Store sync status and extension settings |\n| `alarms` | Keep service worker alive for background sync |\n| `contextMenus` | Add \"Save to FavBox\" to right-click menu |\n| `<all_urls>` | Fetch page metadata (title, description, favicon) |\n\n\n### Building\n1. `pnpm run build` to build into `dist`\n2. Enable dev mode in `chrome://extensions/` and `Load unpacked` extension\n\n### Commands\n\n- **`dev`**  Start development server  \n- **`dev:firefox`**  Firefox development build (WIP)\n- **`build`**  Production build  \n- **`test:unit`** Run unit tests  \n- **`test:integration`**   Run integration tests  \n\n### TODO\n- Use SQLite Wasm for storage (ideal for future experiments)\n- Improve transaction implementation (ensure reliability & better performance)\n- The extension already uses a polyfill to maintain compatibility with other browsers. It would be good to test this in Firefox. (WIP)"
  },
  {
    "path": "manifest.chrome.json",
    "content": "{\n  \"manifest_version\": 3,\n  \"name\": \"FavBox\",\n  \"description\": \"A clean, modern bookmark app — local-first by design.\",\n  \"version\": \"2.1.5\",\n  \"permissions\": [\n    \"bookmarks\",\n    \"activeTab\",\n    \"tabs\",\n    \"storage\",\n    \"alarms\",\n    \"contextMenus\"\n  ],\n  \"host_permissions\": [\n    \"<all_urls>\"\n  ],\n  \"icons\": {\n    \"16\": \"icons/icon16.png\",\n    \"32\": \"icons/icon32.png\",\n    \"48\": \"icons/icon48.png\",\n    \"128\": \"icons/icon128.png\"\n  },\n  \"action\": {\n    \"default_popup\": \"ext/popup/index.html\"\n  },\n  \"commands\": {\n    \"_execute_action\": {\n      \"suggested_key\": {\n        \"windows\": \"Ctrl+Shift+Y\",\n        \"mac\": \"Command+Shift+Y\",\n        \"chromeos\": \"Ctrl+Shift+U\",\n        \"linux\": \"Ctrl+Shift+J\"\n      }\n    }\n  },\n  \"background\": {\n    \"service_worker\": \"ext/sw/index.js\",\n    \"type\": \"module\"\n  },\n  \"content_scripts\": [\n    {\n      \"matches\": [\n        \"<all_urls>\"\n      ],\n      \"js\": [\n        \"ext/content/content.js\"\n      ],\n      \"run_at\": \"document_end\"\n    }\n  ],\n  \"web_accessible_resources\": [\n    {\n      \"resources\": [\n        \"ext/browser/index.html\"\n      ],\n      \"matches\": [\n        \"<all_urls>\"\n      ]\n    }\n  ]\n}"
  },
  {
    "path": "manifest.firefox.json",
    "content": "{\n    \"manifest_version\": 3,\n    \"name\": \"FavBox\",\n    \"description\": \"A clean, modern bookmark app — local-first by design.\",\n    \"version\": \"2.1.5\",\n    \"permissions\": [\n        \"bookmarks\",\n        \"activeTab\",\n        \"tabs\",\n        \"storage\",\n        \"alarms\",\n        \"contextMenus\"\n    ],\n    \"host_permissions\": [\n        \"<all_urls>\"\n    ],\n    \"icons\": {\n        \"16\": \"icons/icon16.png\",\n        \"32\": \"icons/icon32.png\",\n        \"48\": \"icons/icon48.png\",\n        \"128\": \"icons/icon128.png\"\n    },\n    \"action\": {\n        \"default_icon\": {\n            \"16\": \"icons/icon16.png\",\n            \"32\": \"icons/icon32.png\"\n        },\n        \"default_title\": \"FavBox\",\n        \"default_popup\": \"ext/popup/index.html\",\n        \"theme_icons\": [\n            {\n                \"light\": \"icons/icon16.png\",\n                \"dark\": \"icons/icon16.png\",\n                \"size\": 16\n            },\n            {\n                \"light\": \"icons/icon32.png\",\n                \"dark\": \"icons/icon32.png\",\n                \"size\": 32\n            }\n        ]\n    },\n    \"background\": {\n        \"scripts\": [\n            \"ext/sw/index.js\"\n        ],\n        \"persistent\": false\n    },\n    \"content_scripts\": [\n        {\n            \"matches\": [\n                \"<all_urls>\"\n            ],\n            \"js\": [\n                \"ext/content/content.js\"\n            ],\n            \"run_at\": \"document_end\"\n        }\n    ],\n    \"web_accessible_resources\": [\n        {\n          \"resources\": [\n            \"ext/browser/index.html\"\n          ],\n          \"matches\": [\n            \"<all_urls>\"\n          ]\n        }\n      ]\n}"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"favbox\",\n  \"version\": \"2.1.5\",\n  \"private\": false,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"dev:firefox\": \"vite build --mode development -c vite.config.firefox.js\",\n    \"build\": \"vite build\",\n    \"test:unit\": \"vitest --environment jsdom --root tests/unit --disableConsoleIntercept\",\n    \"test:integration\": \"vitest  --environment jsdom --root tests/integration --disableConsoleIntercept\",\n    \"lint\": \"eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix\"\n  },\n  \"dependencies\": {\n    \"@fontsource/sn-pro\": \"^5.2.5\",\n    \"@headlessui/vue\": \"^1.7.21\",\n    \"@number-flow/vue\": \"^0.4.3\",\n    \"@tailwindcss/vite\": \"^4.1.11\",\n    \"@tiptap/extension-bold\": \"^2.9.1\",\n    \"@tiptap/extension-highlight\": \"^2.9.1\",\n    \"@tiptap/extension-italic\": \"^2.9.1\",\n    \"@tiptap/extension-typography\": \"^2.9.1\",\n    \"@tiptap/extension-underline\": \"^2.9.1\",\n    \"@tiptap/pm\": \"^2.9.1\",\n    \"@tiptap/starter-kit\": \"^2.9.1\",\n    \"@tiptap/vue-3\": \"^2.9.1\",\n    \"@vuepic/vue-datepicker\": \"^11.0.2\",\n    \"@vueuse/core\": \"^13.5.0\",\n    \"@zanmato/vue3-treeselect\": \"^0.4.1\",\n    \"fast-average-color\": \"^9.5.0\",\n    \"floating-vue\": \"^5.2.2\",\n    \"jsstore\": \"^4.9.0\",\n    \"linkedom\": \"^0.18.11\",\n    \"node-vibrant\": \"^4.0.3\",\n    \"notiwind\": \"^2.0.2\",\n    \"vue\": \"^3.5.13\",\n    \"vue-next-masonry\": \"^1.1.3\",\n    \"vue-router\": \"^4.5.0\",\n    \"webextension-polyfill\": \"^0.12.0\"\n  },\n  \"devDependencies\": {\n    \"@babel/eslint-parser\": \"^7.28.0\",\n    \"@crxjs/vite-plugin\": \"^2.0.0-beta.26\",\n    \"@iconify/json\": \"^2.2.356\",\n    \"@rushstack/eslint-patch\": \"^1.10.2\",\n    \"@tailwindcss/forms\": \"^0.5.7\",\n    \"@tailwindcss/typography\": \"^0.5.13\",\n    \"@types/dompurify\": \"^3.0.5\",\n    \"@vitejs/plugin-vue\": \"^6.0.0\",\n    \"@vue/test-utils\": \"^2.4.5\",\n    \"autoprefixer\": \"^10.4.21\",\n    \"eslint\": \"^8.57.1\",\n    \"eslint-config-airbnb-base\": \"^15.0.0\",\n    \"eslint-import-resolver-alias\": \"^1.1.2\",\n    \"eslint-plugin-import\": \"^2.32.0\",\n    \"eslint-plugin-jsdoc\": \"^48.11.0\",\n    \"eslint-plugin-vue\": \"^9.33.0\",\n    \"eslint-plugin-vuejs-accessibility\": \"^2.4.1\",\n    \"file-loader\": \"^6.2.0\",\n    \"globals\": \"^16.3.0\",\n    \"jsdom\": \"^26.1.0\",\n    \"postcss\": \"^8.5.6\",\n    \"tailwindcss\": \"^4.1.11\",\n    \"terser\": \"^5.31.0\",\n    \"unplugin-auto-import\": \"^19.3.0\",\n    \"unplugin-icons\": \"^22.1.0\",\n    \"vite\": \"^7.1.11\",\n    \"vite-plugin-eslint\": \"^1.8.1\",\n    \"vitest\": \"^3.0.5\",\n    \"vue-eslint-parser\": \"^10.2.0\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/dd3v/favbox.git\"\n  },\n  \"keywords\": [\n    \"chrome extension\",\n    \"bookmarks\"\n  ],\n  \"author\": \"Magalyas Dmitry\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/dd3v/favbox/issues\"\n  },\n  \"homepage\": \"https://github.com/dd3v/favbox#readme\"\n}\n"
  },
  {
    "path": "public/site.webmanifest",
    "content": "{\"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\"}"
  },
  {
    "path": "src/assets/app.css",
    "content": "@import \"tailwindcss\";\n@plugin \"@tailwindcss/forms\";\n@plugin  \"@tailwindcss/typography\";\n\n@custom-variant dark (&:where(.dark, .dark *));\n\n@layer base {\n  button:not(:disabled),\n  [role=\"button\"]:not(:disabled) {\n    cursor: pointer;\n  }\n  *,\n  ::after,\n  ::before,\n  ::backdrop,\n  ::file-selector-button {\n    border-color: var(--color-gray-200, currentColor);\n  }\n}\n\n@theme {\n  --font-sans: \"SN Pro\", sans-serif; \n  --color-soft-50: oklch(0.999 0.001 0);\n  --color-soft-100: oklch(0.995 0.002 0);\n  --color-soft-200: oklch(0.99 0.003 0);\n  --color-soft-300: oklch(0.98 0.004 0);\n  --color-soft-400: oklch(0.95 0.005 0);\n  --color-soft-500: oklch(0.9 0.006 0);\n  --color-soft-600: oklch(0.85 0.007 0);\n  --color-soft-700: oklch(0.8 0.008 0);\n  --color-soft-800: oklch(0.75 0.009 0);\n  --color-soft-900: oklch(0.7 0.01 0);\n\n  --color-gray-50: oklch(0.985 0 0);\n  --color-gray-100: oklch(0.967 0.001 286.375);\n  --color-gray-200: oklch(0.92 0.004 286.32);\n  --color-gray-300: oklch(0.871 0.006 286.286);\n  --color-gray-400: oklch(0.705 0.015 286.067);\n  --color-gray-500: oklch(0.552 0.016 285.938);\n  --color-gray-600: oklch(0.442 0.017 285.786);\n  --color-gray-700: oklch(0.37 0.013 285.805);\n  --color-gray-800: oklch(0.274 0.006 286.033);\n  --color-gray-900: oklch(0.21 0.006 285.885);\n  --color-gray-950: oklch(0.141 0.005 285.823);\n}\n\n\n@layer utilities {\n\n  :root {\n    --scrollbar-thumb: rgba(0, 0, 0, 0.2);\n    --scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);\n  }\n\n  .dark {\n    --scrollbar-thumb: rgba(255, 255, 255, 0.2);\n    --scrollbar-thumb-hover: rgba(255, 255, 255, 0.3);\n  }\n\n  ::-webkit-scrollbar {\n    width: 1px;\n    height: 1px;\n  }\n\n  ::-webkit-scrollbar-track {\n    background: transparent;\n  }\n\n  ::-webkit-scrollbar-thumb {\n    background: var(--scrollbar-thumb);\n    border-radius: 2px;\n  }\n\n  ::-webkit-scrollbar-thumb:hover {\n    background: var(--scrollbar-thumb-hover);\n  }\n}\n@supports(animation-timeline: view()) {\n  @keyframes fade-in-on-enter--fade-out-on-exit {\n    entry 0% {\n      opacity: 0;\n      transform: translateY(100%);\n    }\n\n    entry 100% {\n      opacity: 1;\n      transform: translateY(0);\n    }\n\n    exit 0% {\n      opacity: 1;\n      transform: translateY(0);\n    }\n\n    exit 100% {\n      opacity: 0;\n      transform: translateY(-100%);\n    }\n  }\n\n  .list-view>ul>li {\n    animation: linear fade-in-on-enter--fade-out-on-exit;\n    animation-timeline: view();\n  }\n}\n\n.vue3-treeselect__single-value {\n  @apply!text-black dark: !text-white;\n}\n\n.vue3-treeselect:not(.vue3-treeselect--disabled):not(.vue3-treeselect--focused) .vue3-treeselect__control:hover {\n  @apply !border-gray-200 dark:!border-neutral-700;\n}\n\n.vue3-treeselect__control {\n  @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;\n}\n\n.vue3-treeselect__input {\n  @apply !text-xs !text-black dark:!text-white;\n}\n\n.vue3-treeselect__input:focus {\n  @apply !ring-0 !outline-none !shadow-none !text-xs;\n}\n\n.vue3-treeselect__menu {\n  @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;\n}\n\n.vue3-treeselect .vue3-treeselect__list div {\n  @apply !leading-[2.5] !py-0 !my-0;\n}\n\n.vue3-treeselect__option--highlight {\n  @apply !bg-gray-100 dark:!bg-neutral-800;\n}\n\n.vue3-treeselect__option vue3-treeselect__option--highlight {\n  @apply !bg-gray-100 dark:!bg-neutral-800;\n}\n\n.vue3-treeselect--open .vue3-treeselect__control {\n  @apply dark:!border-neutral-700;\n}\n\n.vue3-treeselect--single .vue3-treeselect__option--selected {\n  @apply !bg-gray-100 dark:!bg-neutral-800;\n}\n\n.vue3-treeselect--focused:not(.vue3-treeselect--open) .vue3-treeselect__control {\n  @apply !border-gray-200 dark:!border-neutral-700 !shadow-none;\n}\n\n.vue3-treeselect__single-value {\n  @apply !text-black dark:!text-white;\n}\n\n:root .dp__theme_light {\n  --dp-background-color: #fff;\n  --dp-text-color: var(--color-gray-900);\n  --dp-hover-color: var(--color-gray-100);\n  --dp-hover-text-color: var(--color-gray-900);\n  --dp-primary-color: var(--color-gray-600);\n  --dp-primary-text-color: #fff;\n  --dp-border-color: var(--color-gray-200);\n  --dp-menu-border-color: var(--color-gray-200);\n  --dp-font-family: \"SN Pro\", sans-serif;\n  --dp-font-size: 0.75rem;\n}\n\n:root .dp__theme_dark {\n  --dp-background-color: var(--color-gray-950);\n  --dp-text-color: var(--color-gray-100);\n  --dp-hover-color: var(--color-gray-800);\n  --dp-hover-text-color: var(--color-gray-100);\n  --dp-primary-color: var(--color-gray-400);\n  --dp-primary-text-color: var(--color-gray-900);\n  --dp-border-color: var(--color-gray-700);\n  --dp-menu-border-color: var(--color-gray-700);\n  --dp-font-family: \"SN Pro\", sans-serif;\n  --dp-font-size: 0.75rem;\n}\n"
  },
  {
    "path": "src/components/app/AppBadge.vue",
    "content": "<template>\n  <div\n    :class=\"badgeClass\"\n    class=\"inline-flex cursor-pointer gap-x-1 items-center space-x-1 whitespace-nowrap rounded-sm px-2 py-0.5 text-xs ring-1 ring-inset\"\n  >\n    <slot />\n    <button\n      v-if=\"closable\"\n      type=\"button\"\n      :class=\"closeButtonClass\"\n      class=\"size-3 flex items-center justify-center\"\n      aria-label=\"Close\"\n      tabindex=\"0\"\n      @click=\"handleClose\"\n    >\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        fill=\"none\"\n        viewBox=\"0 0 24 24\"\n        stroke-width=\"1\"\n        stroke=\"currentColor\"\n        class=\"size-3\"\n      >\n        <path\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n          d=\"M6 18L18 6M6 6l12 12\"\n        />\n      </svg>\n    </button>\n  </div>\n</template>\n\n<script setup>\nimport { defineEmits, computed } from 'vue';\n\nconst props = defineProps({\n  color: {\n    type: String,\n    default: 'gray',\n  },\n  closable: {\n    type: Boolean,\n    default: false,\n  },\n});\n\nconst emit = defineEmits(['onClose']);\n\nconst badgeClass = computed(() => {\n  const colorClasses = {\n    red: 'bg-red-50 text-red-700 ring-red-700/10 shadow shadow-red-500/20 dark:bg-red-900 dark:text-red-300 dark:ring-red-400/20',\n    yellow: 'bg-yellow-50 text-yellow-800 ring-yellow-700/10 shadow shadow-yellow-500/20 dark:bg-yellow-900 dark:text-yellow-300 dark:ring-yellow-500/20',\n    green: 'bg-green-50 text-green-700 ring-green-700/10 shadow shadow-green-500/20 dark:bg-green-900 dark:text-green-300 dark:ring-green-500/20',\n    cyan: 'bg-cyan-50 text-cyan-700 ring-cyan-700/10 shadow shadow-cyan-500/20 dark:bg-cyan-900 dark:text-cyan-300 dark:ring-cyan-500/20',\n    indigo: 'bg-indigo-50 text-indigo-700 ring-indigo-700/10 shadow shadow-indigo-500/20 dark:bg-indigo-900 dark:text-indigo-300 dark:ring-indigo-500/20',\n    purple: 'bg-purple-50 text-purple-700 ring-purple-700/10 shadow shadow-purple-500/20 dark:bg-purple-900 dark:text-purple-300 dark:ring-purple-500/20',\n    pink: 'bg-pink-50 text-pink-700 ring-pink-700/10 shadow shadow-pink-500/20 dark:bg-pink-900 dark:text-pink-300 dark:ring-pink-500/20',\n    gray: 'bg-gray-50 text-gray-600 ring-gray-700/10 shadow shadow-gray-400/20 dark:bg-neutral-700 dark:text-neutral-300 dark:ring-neutral-500/20',\n  };\n  return colorClasses[props.color] || colorClasses.gray;\n});\n\nconst closeButtonClass = computed(() => {\n  const closeClasses = {\n    red: 'text-red-700 hover:bg-red-300/20 hover:text-red-900 dark:text-red-300 dark:hover:text-red-100',\n    yellow: 'text-yellow-700 hover:bg-yellow-300/20 hover:text-yellow-900 dark:text-yellow-300 dark:hover:text-yellow-100',\n    green: 'text-green-700 hover:bg-green-300/20 hover:text-green-900 dark:text-green-300 dark:hover:text-green-100',\n    cyan: 'text-cyan-700 hover:bg-cyan-300/20 hover:text-cyan-900 dark:text-cyan-300 dark:hover:text-cyan-100',\n    indigo: 'text-indigo-700 hover:bg-indigo-300/20 hover:text-indigo-900 dark:text-indigo-300 dark:hover:text-indigo-100',\n    purple: 'text-purple-700 hover:bg-purple-300/20 hover:text-purple-900 dark:text-purple-300 dark:hover:text-purple-100',\n    pink: 'text-pink-700 hover:bg-pink-300/20 hover:text-pink-900 dark:text-pink-300 dark:hover:text-pink-100',\n    gray: 'text-gray-600 hover:bg-gray-300/20 hover:text-gray-800 dark:text-gray-300 dark:hover:text-gray-100',\n  };\n  return closeClasses[props.color] || closeClasses.gray;\n});\n\nconst handleClose = () => {\n  emit('onClose');\n};\n</script>\n"
  },
  {
    "path": "src/components/app/AppBullet.vue",
    "content": "<template>\n  <span\n    :class=\"[dotClass, dotSize]\"\n    class=\"inline-block rounded-full border\"\n    aria-hidden=\"true\"\n    focusable=\"false\"\n  />\n</template>\n\n<script setup>\nimport { computed } from 'vue';\n\nconst props = defineProps({\n  color: {\n    type: String,\n    default: 'gray',\n  },\n  size: {\n    type: Number,\n    default: 2,\n  },\n});\n\nconst dotSize = computed(() => `size-${props.size}`);\n\nconst dotClass = computed(() => {\n  const colorClasses = {\n    red: 'bg-red-50 border-red-400 dark:bg-red-900 dark:border-red-600',\n    yellow: 'bg-yellow-50 border-yellow-400 dark:bg-yellow-900 dark:border-yellow-600',\n    green: 'bg-green-50 border-green-400 dark:bg-green-900 dark:border-green-600',\n    blue: 'bg-blue-50 border-blue-400 dark:bg-blue-900 dark:border-blue-600',\n    indigo: 'bg-indigo-50 border-indigo-400 dark:bg-indigo-900 dark:border-indigo-600',\n    purple: 'bg-purple-50 border-purple-400 dark:bg-purple-900 dark:border-purple-600',\n    pink: 'bg-pink-50 border-pink-400 dark:bg-pink-900 dark:border-pink-600',\n    cyan: 'bg-cyan-50 border-cyan-400 dark:bg-cyan-900 dark:border-cyan-600',\n    gray: 'bg-gray-50 border-gray-400 dark:bg-neutral-700 dark:border-neutral-500',\n  };\n  return colorClasses[props.color] || colorClasses.gray;\n});\n</script>\n"
  },
  {
    "path": "src/components/app/AppButton.vue",
    "content": "<template>\n  <button\n    :class=\"buttonClasses\"\n    :aria-label=\"ariaLabel\"\n    :title=\"title\"\n    :role=\"role\"\n    tabindex=\"0\"\n  >\n    <slot />\n  </button>\n</template>\n\n<script setup>\nimport { computed } from 'vue';\n\nconst props = defineProps({\n  variant: {\n    type: String,\n    default: 'default',\n  },\n  ariaLabel: {\n    type: String,\n    default: 'Button',\n  },\n  title: {\n    type: String,\n    default: 'Button',\n  },\n  role: {\n    type: String,\n    default: 'button',\n  },\n});\n\nconst buttonClasses = computed(() => {\n  const variantClasses = {\n    default: 'block rounded-sm bg-black px-3 py-2.5 text-xs font-medium text-white border border-gray-800 shadow-xs transition-all duration-200 ease-out hover:bg-gray-900 hover:border-gray-700 hover:shadow-md active:bg-black active:scale-[0.98] active:shadow-xs focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:bg-white dark:text-black dark:border-gray-200 dark:shadow-xs dark:hover:bg-gray-50 dark:hover:border-gray-300 dark:hover:shadow-md dark:focus:ring-blue-400 dark:focus-visible:ring-blue-400',\n    red: 'block rounded-sm bg-red-600 px-3 py-2.5 text-xs font-medium text-white border border-red-500 shadow-xs transition-all duration-200 ease-out hover:bg-red-700 hover:border-red-600 hover:shadow-md active:bg-red-800 active:scale-[0.98] active:shadow-xs focus:outline-hidden focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2',\n    gray: 'block rounded-sm bg-gray-100 px-3 py-2.5 text-xs font-medium text-gray-700 border border-gray-200 shadow-xs transition-all duration-200 ease-out hover:bg-gray-50 hover:border-gray-300 hover:shadow-md hover:text-gray-900 active:bg-gray-100 active:scale-[0.98] active:shadow-xs focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:shadow-xs dark:hover:bg-gray-600 dark:hover:border-gray-500 dark:hover:text-white dark:hover:shadow-md dark:focus:ring-blue-400 dark:focus-visible:ring-blue-400',\n  };\n  return variantClasses[props.variant] || variantClasses.default;\n});\n</script>\n"
  },
  {
    "path": "src/components/app/AppConfirmation.vue",
    "content": "<template>\n  <TransitionRoot\n    as=\"template\"\n    :show=\"isOpen\"\n  >\n    <Dialog\n      class=\"relative z-10\"\n      @close=\"cancel\"\n    >\n      <TransitionChild\n        as=\"template\"\n        enter=\"ease-out duration-300\"\n        enter-from=\"opacity-0\"\n        enter-to=\"opacity-100\"\n        leave=\"ease-in duration-200\"\n        leave-from=\"opacity-100\"\n        leave-to=\"opacity-0\"\n      >\n        <div class=\"fixed inset-0 bg-gray-500/75 transition-opacity\" />\n      </TransitionChild>\n      <div class=\"fixed inset-0 z-10 w-screen overflow-y-auto\">\n        <div class=\"flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0\">\n          <TransitionChild\n            as=\"template\"\n            enter=\"ease-out duration-300\"\n            enter-from=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n            enter-to=\"opacity-100 translate-y-0 sm:scale-100\"\n            leave=\"ease-in duration-200\"\n            leave-from=\"opacity-100 translate-y-0 sm:scale-100\"\n            leave-to=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n          >\n            <DialogPanel class=\"relative overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all dark:bg-black sm:my-8 sm:w-full sm:max-w-lg\">\n              <div class=\"px-4 pb-4 pt-5 sm:p-6 sm:pb-4\">\n                <div class=\"sm:flex sm:items-start\">\n                  <div class=\"mx-auto flex size-12 shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:size-10\">\n                    <svg\n                      class=\"size-6 text-red-600\"\n                      aria-hidden=\"true\"\n                      xmlns=\"http://www.w3.org/2000/svg\"\n                      width=\"1em\"\n                      height=\"1em\"\n                      viewBox=\"0 0 26 26\"\n                    ><path\n                      fill=\"currentColor\"\n                      d=\"M13 0C5.925 0 0 5.08 0 11.5c0 3.03 1.359 5.748 3.5 7.781a6.7 6.7 0 0 1-1.094 1.875A16.5 16.5 0 0 1 .375 23.22A1 1 0 0 0 1 25c2.215 0 3.808-.025 5.25-.406c1.29-.342 2.399-1.058 3.531-2.063c1.03.247 2.093.469 3.219.469c7.075 0 13-5.08 13-11.5S20.075 0 13 0m0 2c6.125 0 11 4.32 11 9.5S19.125 21 13 21c-1.089 0-2.22-.188-3.25-.469a1 1 0 0 0-.938.25c-1.125 1.079-1.954 1.582-3.062 1.875c-.51.135-1.494.103-2.188.157c.14-.158.271-.242.407-.407c.786-.96 1.503-1.975 1.719-3.125a1 1 0 0 0-.344-.937C3.249 16.614 2 14.189 2 11.5C2 6.32 6.875 2 13 2m-1.906 3.906a1 1 0 0 0-.469.25l-1.5 1.407l1.344 1.468l1.187-1.125h2.406L15 8.97v1.469l-2.563 1.718A1 1 0 0 0 12 13v2h2v-1.438l2.563-1.718A1 1 0 0 0 17 11V8.594a1 1 0 0 0-.25-.656l-1.5-1.688a1 1 0 0 0-.75-.344h-3.188a1 1 0 0 0-.218 0M12 16v2h2v-2z\"\n                    /></svg>\n                  </div>\n                  <div class=\"mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left\">\n                    <DialogTitle\n                      as=\"h3\"\n                      class=\"text-base font-semibold leading-6 text-black dark:text-white\"\n                    >\n                      <slot name=\"title\" />\n                    </DialogTitle>\n                    <div class=\"mt-2\">\n                      <p class=\"text-xs text-black dark:text-white\">\n                        <slot name=\"description\" />\n                      </p>\n                    </div>\n                  </div>\n                </div>\n              </div>\n              <div\n                class=\"flex flex-col gap-2 bg-gray-50 px-4 py-3 dark:bg-neutral-950 sm:flex-row-reverse sm:gap-x-3 sm:gap-y-0 sm:px-6\"\n              >\n                <AppButton\n                  variant=\"red\"\n                  class=\"w-full sm:w-auto\"\n                  @click=\"confirm\"\n                >\n                  <slot name=\"confirm\" />\n                </AppButton>\n                <AppButton\n                  ref=\"cancelButtonRef\"\n                  variant=\"gray\"\n                  class=\"w-full sm:w-auto\"\n                  @click=\"cancel\"\n                >\n                  <slot name=\"cancel\" />\n                </AppButton>\n              </div>\n            </DialogPanel>\n          </TransitionChild>\n        </div>\n      </div>\n    </Dialog>\n  </TransitionRoot>\n</template>\n\n<script setup>\nimport { ref } from 'vue';\nimport {\n  Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot,\n} from '@headlessui/vue';\nimport AppButton from './AppButton.vue';\n\nconst isOpen = ref(false);\nlet resolvePromise = null;\n\nconst request = () => new Promise((resolve) => {\n  resolvePromise = resolve;\n  isOpen.value = true;\n});\n\nconst close = () => {\n  isOpen.value = false;\n};\n\nconst confirm = () => {\n  if (resolvePromise) {\n    resolvePromise(true);\n  }\n  close();\n};\n\nconst cancel = () => {\n  if (resolvePromise) {\n    resolvePromise(false);\n  }\n  close();\n};\n\ndefineExpose({ request });\n</script>\n"
  },
  {
    "path": "src/components/app/AppDrawer.vue",
    "content": "<template>\n  <TransitionRoot\n    as=\"template\"\n    :show=\"isOpen\"\n  >\n    <Dialog\n      class=\"relative z-10\"\n      @close=\"isOpen = false\"\n    >\n      <TransitionChild\n        as=\"template\"\n        enter=\"ease-in-out duration-300\"\n        enter-from=\"opacity-0\"\n        enter-to=\"opacity-100\"\n        leave=\"ease-in-out duration-300\"\n        leave-from=\"opacity-100\"\n        leave-to=\"opacity-0\"\n      >\n        <div class=\"fixed inset-0 bg-gray-500/75 transition-opacity\" />\n      </TransitionChild>\n\n      <div class=\"fixed inset-0 overflow-hidden\">\n        <div class=\"absolute inset-0 overflow-hidden\">\n          <div class=\"pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10\">\n            <TransitionChild\n              as=\"template\"\n              enter=\"transform transition ease-in-out duration-300 sm:duration-300\"\n              enter-from=\"translate-x-full\"\n              enter-to=\"translate-x-0\"\n              leave=\"transform transition ease-in-out duration-300 sm:duration-300\"\n              leave-from=\"translate-x-0\"\n              leave-to=\"translate-x-full\"\n            >\n              <DialogPanel class=\"pointer-events-auto relative w-screen max-w-md\">\n                <TransitionChild\n                  as=\"template\"\n                  enter=\"ease-in-out duration-300\"\n                  enter-from=\"opacity-0\"\n                  enter-to=\"opacity-100\"\n                  leave=\"ease-in-out duration-300\"\n                  leave-from=\"opacity-100\"\n                  leave-to=\"opacity-0\"\n                >\n                  <div class=\"absolute left-0 top-0 -ml-8 flex pr-2 pt-4 sm:-ml-10 sm:pr-4\">\n                    <button\n                      type=\"button\"\n                      class=\"relative rounded-md text-gray-300 hover:text-white focus:outline-none focus:ring-2 focus:ring-white\"\n                      @click=\"isOpen = false\"\n                    >\n                      <span class=\"absolute -inset-2.5\" />\n                      <span class=\"sr-only\">Close panel</span>\n                      <svg\n                        class=\"size-6\"\n                        xmlns=\"http://www.w3.org/2000/svg\"\n                        width=\"1em\"\n                        height=\"1em\"\n                        viewBox=\"0 0 24 24\"\n                      ><path\n                        fill=\"currentColor\"\n                        d=\"m6.4 18.308l-.708-.708l5.6-5.6l-5.6-5.6l.708-.708l5.6 5.6l5.6-5.6l.708.708l-5.6 5.6l5.6 5.6l-.708.708l-5.6-5.6z\"\n                      /></svg>\n                    </button>\n                  </div>\n                </TransitionChild>\n                <div class=\"flex h-full flex-col overflow-y-scroll bg-white py-6 shadow-xl dark:bg-black\">\n                  <div class=\"px-4 sm:px-6\">\n                    <DialogTitle class=\"text-base font-semibold leading-6 text-black dark:text-white\">\n                      <slot name=\"title\" />\n                    </DialogTitle>\n                  </div>\n                  <div class=\"relative mt-6 flex flex-1 justify-center  px-4 sm:px-6\">\n                    <slot name=\"content\" />\n                  </div>\n                </div>\n              </DialogPanel>\n            </TransitionChild>\n          </div>\n        </div>\n      </div>\n    </Dialog>\n  </TransitionRoot>\n</template>\n\n<script setup>\nimport {\n  Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot,\n} from '@headlessui/vue';\nimport { ref } from 'vue';\n\nconst isOpen = ref(false);\n\nconst open = () => {\n  isOpen.value = true;\n};\n\nconst close = () => {\n  isOpen.value = false;\n};\ndefineExpose({ open, close });\n</script>\n"
  },
  {
    "path": "src/components/app/AppInfiniteScroll.vue",
    "content": "<template>\n  <div ref=\"scroll\">\n    <slot />\n  </div>\n</template>\n<script setup>\nimport { onMounted, onBeforeUnmount, ref, useTemplateRef } from 'vue';\n\nconst props = defineProps({ limit: { type: Number, default: 50 } });\nconst emit = defineEmits(['scroll:end']);\nconst scrollRef = useTemplateRef('scroll');\nconst skip = ref(0);\nlet lastScrollTop = 0;\n\nconst scrollUp = () => {\n  skip.value = 0;\n  scrollRef.value.scrollTo({\n    top: 0,\n    behavior: 'smooth',\n  });\n};\n\nconst throttle = (func, limit) => {\n  let inThrottle = false;\n  return (...args) => {\n    const context = this;\n    if (!inThrottle) {\n      func.apply(context, args);\n      inThrottle = true;\n      setTimeout(() => {\n        inThrottle = false;\n      }, limit);\n    }\n  };\n};\n\nconst onScroll = () => {\n  const el = scrollRef.value;\n  const currentScrollTop = el.scrollTop;\n  const isScrollingDown = currentScrollTop > lastScrollTop;\n  lastScrollTop = currentScrollTop;\n  if (!isScrollingDown) return;\n  if (Math.round(el.offsetHeight + currentScrollTop) >= el.scrollHeight * 0.75) {\n    skip.value += parseInt(props.limit, 10);\n    emit('scroll:end', skip.value);\n  }\n};\n\nconst throttledScroll = throttle(onScroll, 200);\n\nonMounted(() => scrollRef.value.addEventListener('scroll', throttledScroll));\nonBeforeUnmount(() => { scrollRef.value?.removeEventListener('scroll', throttledScroll); });\n\ndefineExpose({ scrollRef, scrollUp, skip: skip.value });\n</script>\n"
  },
  {
    "path": "src/components/app/AppNotifications.vue",
    "content": "<template>\n  <div class=\"flex\">\n    <!-- Error Notifications -->\n    <NotificationGroup group=\"error\">\n      <div\n        class=\"pointer-events-none fixed inset-0 z-50 flex items-end justify-center p-6\"\n      >\n        <div class=\"w-full max-w-xs\">\n          <Notification\n            v-slot=\"{ notifications }\"\n            enter=\"transform ease-out duration-300 transition\"\n            enter-from=\"translate-y-full opacity-0\"\n            enter-to=\"translate-y-0 opacity-100\"\n            leave=\"transition ease-in duration-500\"\n            leave-from=\"opacity-100\"\n            leave-to=\"opacity-0\"\n            move=\"transition duration-500\"\n            move-delay=\"delay-300\"\n          >\n            <div\n              v-for=\"notification in notifications\"\n              :key=\"notification.id\"\n              role=\"alert\"\n              class=\"mx-auto mt-4 flex w-full max-w-xs items-center gap-3 overflow-hidden rounded-lg border border-neutral-400/30 bg-red-600/60 p-3 text-sm shadow-lg backdrop-blur-md dark:border-white/10 dark:bg-red-800/80 dark:text-white\"\n            >\n              <div class=\"flex items-center justify-center text-white\">\n                <svg\n                  class=\"size-5\"\n                  viewBox=\"0 0 40 40\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  fill=\"currentColor\"\n                >\n                  <path\n                    d=\"M20 3.36667C10.8167 3.36667 3.3667 10.8167 3.3667 20C3.3667 29.1833 10.8167 36.6333 20 36.6333C29.1834 36.6333 36.6334 29.1833 36.6334 20C36.6334 10.8167 29.1834 3.36667 20 3.36667ZM19.1334 33.3333V22.9H13.3334L21.6667 6.66667V17.1H27.25L19.1334 33.3333Z\"\n                  />\n                </svg>\n              </div>\n              <div class=\"flex-1 text-xs text-white\">\n                {{ notification.text }}\n              </div>\n            </div>\n          </Notification>\n        </div>\n      </div>\n    </NotificationGroup>\n\n    <!-- Default Notifications -->\n    <NotificationGroup group=\"default\">\n      <div\n        class=\"pointer-events-none fixed inset-0 z-50 flex items-end justify-center p-6\"\n      >\n        <div class=\"w-full max-w-xs\">\n          <Notification\n            v-slot=\"{ notifications }\"\n            enter=\"transform ease-out duration-300 transition\"\n            enter-from=\"translate-y-full opacity-0\"\n            enter-to=\"translate-y-0 opacity-100\"\n            leave=\"transition ease-in duration-500\"\n            leave-from=\"opacity-100\"\n            leave-to=\"opacity-0\"\n            move=\"transition duration-500\"\n            move-delay=\"delay-300\"\n          >\n            <div\n              v-for=\"notification in notifications\"\n              :key=\"notification.id\"\n              role=\"alert\"\n              class=\"mx-auto mt-4 flex w-full max-w-xs items-center gap-3 overflow-hidden rounded-lg border border-neutral-400/30 bg-green-700/60 p-3 text-sm shadow-lg backdrop-blur-md dark:border-white/10 dark:bg-green-800/80 dark:text-white\"\n            >\n              <div class=\"flex items-center justify-center text-white\">\n                <svg\n                  class=\"size-5\"\n                  viewBox=\"0 0 40 40\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  fill=\"currentColor\"\n                >\n                  <path\n                    d=\"M20 3.33331C10.8 3.33331 3.33337 10.8 3.33337 20C3.33337 29.2 10.8 36.6666 20 36.6666C29.2 36.6666 36.6667 29.2 36.6667 20C36.6667 10.8 29.2 3.33331 20 3.33331ZM21.6667 28.3333H18.3334V25H21.6667V28.3333ZM21.6667 21.6666H18.3334V11.6666H21.6667V21.6666Z\"\n                  />\n                </svg>\n              </div>\n              <div class=\"flex-1 text-xs text-white\">\n                {{ notification.text }}\n              </div>\n            </div>\n          </Notification>\n        </div>\n      </div>\n    </NotificationGroup>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/app/AppProgress.vue",
    "content": "<template>\n  <div\n    class=\"overflow-hidden rounded-md border border-gray-100 bg-gray-50 p-0.5 shadow-none\"\n    role=\"progressbar\"\n    :aria-valuenow=\"progress\"\n    aria-valuemin=\"0\"\n    aria-valuemax=\"100\"\n    :aria-label=\"`Progress: ${progress}%`\"\n  >\n    <div class=\"relative flex items-center justify-center\">\n      <div\n        class=\"absolute inset-y-0 left-0 rounded-lg bg-black transition-all duration-300 ease-out\"\n        :style=\"{ width: progress + '%' }\"\n      />\n      <div class=\"relative text-xs text-white mix-blend-difference\">\n        {{ progress }}%\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\ndefineProps({\n  progress: {\n    type: Number,\n    default: 0,\n    required: true,\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/app/AppRadio.vue",
    "content": "<template>\n  <label\n    :for=\"inputId\"\n    class=\"group flex cursor-pointer items-center gap-x-2\"\n  >\n    <input\n      :id=\"inputId\"\n      v-model=\"model\"\n      type=\"radio\"\n      :name=\"name\"\n      :value=\"value\"\n      :aria-label=\"label\"\n      class=\"size-4 rounded-full border-gray-300 text-black focus:ring-0 dark:border-neutral-700 dark:bg-neutral-800 dark:checked:bg-black dark:focus:ring-offset-gray-800\"\n    >\n    <span class=\"text-xs text-black dark:text-white\">{{ label }}</span>\n  </label>\n</template>\n<script setup>\nimport { useId } from 'vue';\n\nconst model = defineModel({ type: String, required: true });\ndefineProps({\n  value: {\n    type: String,\n    required: true,\n  },\n  label: {\n    type: String,\n    required: true,\n  },\n  name: {\n    type: String,\n    required: true,\n  },\n});\n\nconst inputId = useId();\n</script>\n"
  },
  {
    "path": "src/components/app/AppSpinner.vue",
    "content": "<template>\n  <div role=\"status\">\n    <svg\n      aria-hidden=\"true\"\n      class=\"inline size-8 animate-spin fill-black text-gray-200 dark:fill-gray-300 dark:text-gray-600\"\n      viewBox=\"0 0 100 101\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        d=\"M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z\"\n        fill=\"currentColor\"\n      />\n      <path\n        d=\"M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z\"\n        fill=\"currentFill\"\n      />\n    </svg>\n    <span class=\"sr-only\">Loading...</span>\n  </div>\n</template>\n<script setup>\n</script>\n"
  },
  {
    "path": "src/components/app/AppTagInput.vue",
    "content": "<template>\n  <div\n    role=\"combobox\"\n    :aria-expanded=\"showSuggestionContainer\"\n    aria-haspopup=\"listbox\"\n    aria-controls=\"tag-suggestion-list\"\n    tabindex=\"0\"\n    @click=\"focus\"\n    @keydown.enter=\"focus\"\n  >\n    <div\n      class=\"flex min-h-9 w-full flex-wrap items-center gap-1 whitespace-normal rounded-md border border-gray-200 bg-white px-2 py-1 shadow-sm focus-within:border-gray-300 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white focus-within:dark:border-neutral-700\"\n      aria-label=\"Tag input\"\n    >\n      <AppBadge\n        v-for=\"(value, index) in tags\"\n        :key=\"index\"\n        closable\n        :data-tag=\"value\"\n        @on-close=\"remove(index)\"\n      >\n        <span class=\"whitespace-nowrap text-xs\">{{ value }}</span>\n      </AppBadge>\n      <input\n        ref=\"inputRef\"\n        v-model=\"tag\"\n        class=\"min-w-[20%] flex-1 appearance-none border-0 bg-transparent px-1 py-0 text-xs placeholder:text-xs focus:outline-none focus:ring-0\"\n        type=\"text\"\n        maxlength=\"25\"\n        :placeholder=\"tags.length ? '' : placeholder\"\n        aria-label=\"Tag input\"\n        @keydown.enter.prevent=\"add\"\n        @keydown.delete=\"removeLast\"\n        @keydown.arrow-up.prevent=\"arrowUp\"\n        @keydown.arrow-down.prevent=\"arrowDown\"\n        @keydown.escape=\"hideSuggestions\"\n      >\n    </div>\n    <transition\n      enter-active-class=\"transition duration-200 ease-out\"\n      enter-from-class=\"translate-y-1 opacity-0\"\n      enter-to-class=\"translate-y-0 opacity-100\"\n      leave-active-class=\"transition duration-150 ease-in\"\n      leave-from-class=\"translate-y-0 opacity-100\"\n      leave-to-class=\"translate-y-1 opacity-0\"\n    >\n      <div\n        v-if=\"showSuggestionContainer\"\n        id=\"tag-suggestion-list\"\n        class=\"z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white text-xs shadow-lg ring-1 ring-black/5 focus:outline-none dark:bg-neutral-900 dark:text-neutral-400\"\n        role=\"listbox\"\n      >\n        <ul>\n          <li\n            v-for=\"(suggestion, index) in filteredSuggestions\"\n            :id=\"`suggestion-${index}`\"\n            :key=\"index\"\n            ref=\"suggestionRef\"\n            class=\"block cursor-pointer px-4 py-2 hover:bg-gray-100 dark:hover:bg-neutral-800\"\n            :class=\"{'bg-neutral-100 dark:bg-neutral-800': highlightedSuggestionIndex === index }\"\n            role=\"option\"\n            :aria-selected=\"highlightedSuggestionIndex === index\"\n            tabindex=\"-1\"\n            @click=\"add\"\n            @mouseenter=\"highlightedSuggestionIndex = index\"\n            @focus=\"highlightedSuggestionIndex = index\"\n            @focusin=\"highlightedSuggestionIndex = index\"\n            @keydown.enter=\"add\"\n            @keydown.space.prevent=\"add\"\n          >\n            <div class=\"inline-flex items-center gap-x-1 dark:text-white\">\n              <PhHashStraightLight class=\"size-4\" />\n              <span>{{ suggestion }}</span>\n            </div>\n          </li>\n        </ul>\n      </div>\n    </transition>\n  </div>\n</template>\n\n<script setup>\nimport {\n  ref, computed, watch, onMounted, onUnmounted, nextTick,\n} from 'vue';\nimport AppBadge from '@/components/app/AppBadge.vue';\nimport PhHashStraightLight from '~icons/ph/hash-straight-light';\n\nconst props = defineProps({\n  max: { type: Number, default: 5 },\n  placeholder: { type: String, default: 'Enter a tag' },\n  modelValue: { type: Array, default: () => [] },\n  suggestions: { type: Array, default: () => [] },\n});\n\nconst showSuggestionContainer = ref(false);\nconst highlightedSuggestionIndex = ref(-1);\nconst inputRef = ref(null);\nconst emit = defineEmits(['update:modelValue']);\nconst tag = ref('');\nconst suggestionRef = ref([]);\n\nconst tags = computed({\n  get: () => props.modelValue,\n  set: (value) => emit('update:modelValue', value),\n});\n\nconst focus = () => {\n  nextTick(() => {\n    if (inputRef.value) {\n      inputRef.value.focus();\n    }\n  });\n};\n\nconst remove = (index) => {\n  tags.value.splice(index, 1);\n};\n\nconst removeLast = () => {\n  if (!tag.value) tags.value.pop();\n};\n\nconst filteredSuggestions = computed(() => (tag.value === '' ? [] : props.suggestions.filter((suggestion) => suggestion.toLowerCase().includes(tag.value.toLowerCase()))));\n\nconst add = () => {\n  const value = highlightedSuggestionIndex.value !== -1\n    ? filteredSuggestions.value[highlightedSuggestionIndex.value]\n    : tag.value;\n\n  if (value && !tags.value.includes(value) && tags.value.length < props.max) {\n    tags.value.push(value);\n  }\n  tag.value = '';\n  showSuggestionContainer.value = false;\n};\n\nconst scrollIntoView = () => {\n  const currentElement = suggestionRef.value[highlightedSuggestionIndex.value];\n  if (currentElement) {\n    currentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });\n  }\n};\n\nconst arrowUp = () => {\n  if (highlightedSuggestionIndex.value !== 0) {\n    highlightedSuggestionIndex.value -= 1;\n    scrollIntoView();\n  }\n};\n\nconst arrowDown = () => {\n  if (filteredSuggestions.value.length > highlightedSuggestionIndex.value + 1) {\n    highlightedSuggestionIndex.value += 1;\n    scrollIntoView();\n  }\n};\n\nconst hideSuggestions = () => {\n  showSuggestionContainer.value = false;\n};\n\nwatch(tag, () => {\n  highlightedSuggestionIndex.value = -1;\n  showSuggestionContainer.value = tag.value && filteredSuggestions.value.length > 0;\n});\n\nonMounted(() => {\n  document.addEventListener('click', hideSuggestions);\n});\n\nonUnmounted(() => {\n  document.removeEventListener('click', hideSuggestions);\n});\n</script>\n"
  },
  {
    "path": "src/composables/useColorExtraction.js",
    "content": "import { FastAverageColor } from 'fast-average-color';\nimport { ref } from 'vue';\n\nexport default function useColorExtraction() {\n  const placeholder = ref({\n    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%)',\n    transition: 'background 0.8s ease',\n  });\n\n  const extract = async (url, cacheKey) => {\n    if (!url) return;\n    const cached = localStorage.getItem(cacheKey);\n    if (cached) {\n      const style = JSON.parse(cached);\n      placeholder.value = style.placeholder;\n      return;\n    }\n    try {\n      const fac = new FastAverageColor();\n      const color = await fac.getColorAsync(url);\n      const [r, g, b] = color.value;\n      const saturate = (v) => Math.min(255, Math.round(v * 1.2));\n      const style = {\n        placeholder: {\n          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%)`,\n          transition: 'background 0.8s ease',\n        },\n      };\n      localStorage.setItem(cacheKey, JSON.stringify(style));\n      placeholder.value = style.placeholder;\n    } catch (e) {\n      console.warn('Color extraction failed, using default', e);\n    }\n  };\n\n  return { placeholder, extract };\n}\n"
  },
  {
    "path": "src/constants/app.js",
    "content": "export const PAGINATION_LIMIT = 100;\nexport const NOTIFICATION_DURATION = 3000;\n"
  },
  {
    "path": "src/constants/httpStatus.js",
    "content": "export const HTTP_STATUS = {\n  CONTINUE: 100,\n  SWITCHING_PROTOCOLS: 101,\n  PROCESSING: 102,\n  EARLY_HINTS: 103,\n\n  OK: 200,\n  CREATED: 201,\n  ACCEPTED: 202,\n  NON_AUTHORITATIVE_INFORMATION: 203,\n  NO_CONTENT: 204,\n  RESET_CONTENT: 205,\n  PARTIAL_CONTENT: 206,\n  MULTI_STATUS: 207,\n  ALREADY_REPORTED: 208,\n  IM_USED: 226,\n\n  MULTIPLE_CHOICES: 300,\n  MOVED_PERMANENTLY: 301,\n  FOUND: 302,\n  SEE_OTHER: 303,\n  NOT_MODIFIED: 304,\n  USE_PROXY: 305,\n  TEMPORARY_REDIRECT: 307,\n  PERMANENT_REDIRECT: 308,\n\n  BAD_REQUEST: 400,\n  UNAUTHORIZED: 401,\n  PAYMENT_REQUIRED: 402,\n  FORBIDDEN: 403,\n  NOT_FOUND: 404,\n  METHOD_NOT_ALLOWED: 405,\n  NOT_ACCEPTABLE: 406,\n  PROXY_AUTHENTICATION_REQUIRED: 407,\n  REQUEST_TIMEOUT: 408,\n  CONFLICT: 409,\n  GONE: 410,\n  LENGTH_REQUIRED: 411,\n  PRECONDITION_FAILED: 412,\n  CONTENT_TOO_LARGE: 413,\n  URI_TOO_LONG: 414,\n  UNSUPPORTED_MEDIA_TYPE: 415,\n  RANGE_NOT_SATISFIABLE: 416,\n  EXPECTATION_FAILED: 417,\n  IM_A_TEAPOT: 418,\n  MISDIRECTED_REQUEST: 421,\n  UNPROCESSABLE_ENTITY: 422,\n  LOCKED: 423,\n  FAILED_DEPENDENCY: 424,\n  TOO_EARLY: 425,\n  UPGRADE_REQUIRED: 426,\n  PRECONDITION_REQUIRED: 428,\n  TOO_MANY_REQUESTS: 429,\n  REQUEST_HEADER_FIELDS_TOO_LARGE: 431,\n\n  INTERNAL_SERVER_ERROR: 500,\n  NOT_IMPLEMENTED: 501,\n  BAD_GATEWAY: 502,\n  SERVICE_UNAVAILABLE: 503,\n  GATEWAY_TIMEOUT: 504,\n  HTTP_VERSION_NOT_SUPPORTED: 505,\n  VARIANT_ALSO_NEGOTIATES: 506,\n  INSUFFICIENT_STORAGE: 507,\n  LOOP_DETECTED: 508,\n  NOT_EXTENDED: 510,\n  NETWORK_AUTHENTICATION_REQUIRED: 511,\n  UNKNOWN_ERROR: 520,\n  WEB_SERVER_IS_DOWN: 521,\n  NETWORK_TIMEOUT_ERROR: 599,\n};\n\nexport const STATUS_MESSAGE = new Map([\n  [HTTP_STATUS.CONTINUE, 'Continue'],\n  [HTTP_STATUS.SWITCHING_PROTOCOLS, 'Switching Protocols'],\n  [HTTP_STATUS.PROCESSING, 'Processing'],\n  [HTTP_STATUS.EARLY_HINTS, 'Early Hints'],\n\n  [HTTP_STATUS.OK, 'OK'],\n  [HTTP_STATUS.CREATED, 'Created'],\n  [HTTP_STATUS.ACCEPTED, 'Accepted'],\n  [HTTP_STATUS.NON_AUTHORITATIVE_INFORMATION, 'Non-Authoritative Information'],\n  [HTTP_STATUS.NO_CONTENT, 'No Content'],\n  [HTTP_STATUS.RESET_CONTENT, 'Reset Content'],\n  [HTTP_STATUS.PARTIAL_CONTENT, 'Partial Content'],\n  [HTTP_STATUS.MULTI_STATUS, 'Multi-Status'],\n  [HTTP_STATUS.ALREADY_REPORTED, 'Already Reported'],\n  [HTTP_STATUS.IM_USED, 'IM Used'],\n\n  [HTTP_STATUS.MULTIPLE_CHOICES, 'Multiple Choices'],\n  [HTTP_STATUS.MOVED_PERMANENTLY, 'Moved Permanently'],\n  [HTTP_STATUS.FOUND, 'Found'],\n  [HTTP_STATUS.SEE_OTHER, 'See Other'],\n  [HTTP_STATUS.NOT_MODIFIED, 'Not Modified'],\n  [HTTP_STATUS.USE_PROXY, 'Use Proxy'],\n  [HTTP_STATUS.TEMPORARY_REDIRECT, 'Temporary Redirect'],\n  [HTTP_STATUS.PERMANENT_REDIRECT, 'Permanent Redirect'],\n\n  [HTTP_STATUS.BAD_REQUEST, 'Bad Request'],\n  [HTTP_STATUS.UNAUTHORIZED, 'Unauthorized'],\n  [HTTP_STATUS.PAYMENT_REQUIRED, 'Payment Required'],\n  [HTTP_STATUS.FORBIDDEN, 'Forbidden'],\n  [HTTP_STATUS.NOT_FOUND, 'Not Found'],\n  [HTTP_STATUS.METHOD_NOT_ALLOWED, 'Method Not Allowed'],\n  [HTTP_STATUS.NOT_ACCEPTABLE, 'Not Acceptable'],\n  [HTTP_STATUS.PROXY_AUTHENTICATION_REQUIRED, 'Proxy Authentication Required'],\n  [HTTP_STATUS.REQUEST_TIMEOUT, 'Request Timeout'],\n  [HTTP_STATUS.CONFLICT, 'Conflict'],\n  [HTTP_STATUS.GONE, 'Gone'],\n  [HTTP_STATUS.LENGTH_REQUIRED, 'Length Required'],\n  [HTTP_STATUS.PRECONDITION_FAILED, 'Precondition Failed'],\n  [HTTP_STATUS.CONTENT_TOO_LARGE, 'Content Too Large'],\n  [HTTP_STATUS.URI_TOO_LONG, 'URI Too Long'],\n  [HTTP_STATUS.UNSUPPORTED_MEDIA_TYPE, 'Unsupported Media Type'],\n  [HTTP_STATUS.RANGE_NOT_SATISFIABLE, 'Range Not Satisfiable'],\n  [HTTP_STATUS.EXPECTATION_FAILED, 'Expectation Failed'],\n  [HTTP_STATUS.IM_A_TEAPOT, 'I\\'m a teapot'],\n  [HTTP_STATUS.MISDIRECTED_REQUEST, 'Misdirected Request'],\n  [HTTP_STATUS.UNPROCESSABLE_ENTITY, 'Unprocessable Entity'],\n  [HTTP_STATUS.LOCKED, 'Locked'],\n  [HTTP_STATUS.FAILED_DEPENDENCY, 'Failed Dependency'],\n  [HTTP_STATUS.TOO_EARLY, 'Too Early'],\n  [HTTP_STATUS.UPGRADE_REQUIRED, 'Upgrade Required'],\n  [HTTP_STATUS.PRECONDITION_REQUIRED, 'Precondition Required'],\n  [HTTP_STATUS.TOO_MANY_REQUESTS, 'Too Many Requests'],\n  [HTTP_STATUS.REQUEST_HEADER_FIELDS_TOO_LARGE, 'Request Header Fields Too Large'],\n\n  [HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Internal Server Error'],\n  [HTTP_STATUS.NOT_IMPLEMENTED, 'Not Implemented'],\n  [HTTP_STATUS.BAD_GATEWAY, 'Bad Gateway'],\n  [HTTP_STATUS.SERVICE_UNAVAILABLE, 'Service Unavailable'],\n  [HTTP_STATUS.GATEWAY_TIMEOUT, 'Gateway Timeout'],\n  [HTTP_STATUS.HTTP_VERSION_NOT_SUPPORTED, 'HTTP Version Not Supported'],\n  [HTTP_STATUS.VARIANT_ALSO_NEGOTIATES, 'Variant Also Negotiates'],\n  [HTTP_STATUS.INSUFFICIENT_STORAGE, 'Insufficient Storage'],\n  [HTTP_STATUS.LOOP_DETECTED, 'Loop Detected'],\n  [HTTP_STATUS.NOT_EXTENDED, 'Not Extended'],\n  [HTTP_STATUS.NETWORK_AUTHENTICATION_REQUIRED, 'Network Authentication Required'],\n  [HTTP_STATUS.UNKNOWN_ERROR, 'Unknown error. Data retrieval failed. Check bookmarks manually; data may be incomplete.'],\n  [HTTP_STATUS.WEB_SERVER_IS_DOWN, ' Web server is down'],\n  [HTTP_STATUS.NETWORK_TIMEOUT_ERROR, 'Network Timeout Error'],\n]);\n"
  },
  {
    "path": "src/ext/browser/app.js",
    "content": "import { createApp } from 'vue';\nimport masonry from 'vue-next-masonry';\nimport Notifications from 'notiwind';\nimport FloatingVue from 'floating-vue';\nimport router from './router';\nimport AppLayout from './layouts/AppLayout.vue';\nimport 'floating-vue/dist/style.css';\nimport '@zanmato/vue3-treeselect/dist/vue3-treeselect.min.css';\nimport '@fontsource/sn-pro';\nimport '@vuepic/vue-datepicker/dist/main.css';\nimport '@/assets/app.css';\n\nconst app = createApp(AppLayout)\n  .use(router)\n  .use(masonry)\n  .use(Notifications)\n  .use(FloatingVue);\n\napp.mount('#app');\n"
  },
  {
    "path": "src/ext/browser/components/ASide.vue",
    "content": "<template>\n  <aside\n    class=\"flex flex-col h-full min-h-0 w-14 min-w-14 border-soft-900 bg-soft-50 shadow-inner dark:border-r dark:border-neutral-900 dark:bg-neutral-950 relative\"\n  >\n    <div class=\"flex flex-col items-center pt-3 pb-2 shrink-0\">\n      <RiBookmarkFill class=\"size-8 fill-black text-black dark:fill-white dark:text-white\" />\n    </div>\n    <div\n      ref=\"indicatorRef\"\n      class=\"absolute left-1/2 -translate-x-1/2 size-10 rounded-md bg-gray-400/20 transition-all duration-500 dark:bg-neutral-800 pointer-events-none z-10\"\n    />\n    <ul class=\"flex-1 flex flex-col items-center justify-center gap-y-8 overflow-auto py-4 min-h-[60px] max-h-full relative z-10\">\n      <li\n        v-for=\"(item, key) in items\"\n        :key=\"item.key\"\n        :ref=\"el => setMenuItemRef(el, item.name)\"\n      >\n        <RouterLink\n          :key=\"key\"\n          v-tooltip.right=\"{ content: item.tooltip }\"\n          :to=\"{ name: item.name }\"\n          class=\"relative\"\n          tabindex=\"0\"\n          @click=\"handleClick\"\n          @keydown.enter=\"handleClick\"\n        >\n          <component\n            :is=\"item.icon\"\n            class=\"pointer-events-none size-6 text-black dark:text-white\"\n          />\n        </RouterLink>\n      </li>\n    </ul>\n    <div class=\"flex flex-col items-center gap-y-5 py-3 mt-auto shrink-0\">\n      <ThemeMode\n        v-tooltip.right=\"{ content: 'Theme' }\"\n      />\n      <a\n        v-tooltip.right=\"{ content: 'GitHub' }\"\n        href=\"https://github.com/dd3v/favbox\"\n        target=\"_blank\"\n        aria-label=\"GitHub repository\"\n      >\n        <IconoirGithub class=\"size-4 text-soft-900 hover:text-black dark:text-white dark:hover:text-white\" />\n      </a>\n    </div>\n  </aside>\n</template>\n<script setup>\nimport {\n  defineProps, ref, onMounted, onBeforeUnmount, reactive, watch,\n} from 'vue';\nimport { useRoute } from 'vue-router';\n\nimport ThemeMode from '@/ext/browser/components/ThemeMode.vue';\nimport RiBookmarkFill from '~icons/ri/bookmark-fill';\nimport IconoirGithub from '~icons/iconoir/github';\n\ndefineProps({\n  items: {\n    type: Array,\n    required: true,\n  },\n});\n\nconst route = useRoute();\nconst indicatorRef = ref(null);\nconst menuItemsRef = reactive({});\n\nconst setMenuItemRef = (el, name) => {\n  menuItemsRef[name] = el;\n};\n\nconst setIndicatorPosition = (element) => {\n  const targetRect = element.getBoundingClientRect();\n  const center = targetRect.top + targetRect.height / 2;\n  indicatorRef.value.style.top = `${center - (indicatorRef.value.offsetHeight / 2)}px`;\n};\n\nconst moveButton = () => {\n  const currentItem = menuItemsRef[route.name];\n  if (currentItem) {\n    setIndicatorPosition(currentItem);\n  }\n};\n\nconst handleClick = (event) => {\n  const targetElement = event.currentTarget;\n  setIndicatorPosition(targetElement);\n};\n\nonMounted(() => {\n  window.addEventListener('resize', moveButton);\n});\n\nonBeforeUnmount(() => {\n  window.removeEventListener('resize', moveButton);\n});\n\nwatch(() => route.name, () => {\n  moveButton();\n}, { immediate: true });\n</script>\n<style scoped>\naside {\n  box-shadow: inset 0 2px 16px 0 rgba(60,60,60,0.14);\n}\n.dark aside {\n  box-shadow: none;\n}\n</style>\n"
  },
  {
    "path": "src/ext/browser/components/AttributeList.vue",
    "content": "<template>\n  <div class=\"flex h-full flex-col\">\n    <div class=\"flex w-full\">\n      <div class=\"relative w-full\">\n        <div class=\"absolute top-2 flex items-center pl-2\">\n          <MaterialSymbolsLightCategorySearchOutline class=\"size-5 text-gray-400 dark:text-white\" />\n        </div>\n        <input\n          v-model=\"term\"\n          autocomplete=\"off\"\n          type=\"text\"\n          aria-label=\"Search attributes\"\n          class=\"mb-2 h-9 w-full rounded-md border-1 border-gray-300/50 px-9 text-xs text-black shadow-sm outline-none placeholder:text-xs focus:border-gray-400/30 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white focus:dark:border-neutral-700\"\n        >\n        <Popover class=\"relative\">\n          <PopoverButton\n            ref=\"popoverButtonRef\"\n            class=\"pointer-events-auto absolute inset-y-1 -top-12 right-0 flex items-center pr-2 focus:outline-none focus:ring-0\"\n          >\n            <div class=\"flex flex-wrap items-center gap-x-1 text-sm text-gray-400 dark:text-neutral-600\">\n              <kbd class=\"inline-flex size-6 items-center justify-center rounded-md border border-gray-200 bg-white font-mono text-lg shadow-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200\">\n                ⌘\n              </kbd>\n              <kbd class=\"inline-flex size-6 items-center justify-center rounded-md border border-gray-200 bg-white font-mono text-xs shadow-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200\">\n                /\n              </kbd>\n            </div>\n          </PopoverButton>\n          <Transition\n            enter-active-class=\"transition duration-200 ease-out\"\n            enter-from-class=\"translate-y-1 opacity-0\"\n            enter-to-class=\"translate-y-0 opacity-100\"\n            leave-active-class=\"transition duration-150 ease-in\"\n            leave-from-class=\"translate-y-0 opacity-100\"\n            leave-to-class=\"translate-y-1 opacity-0\"\n          >\n            <PopoverPanel\n              class=\"absolute right-1 z-50 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 dark:bg-neutral-900 dark:text-neutral-400\"\n            >\n              <div class=\"relative\">\n                <div class=\"flex flex-col gap-y-3 p-4\">\n                  <div class=\"flex items-center justify-between\">\n                    <div class=\"flex items-center gap-x-2\">\n                      <PhArrowsDownUp class=\"size-5 text-gray-800 dark:text-gray-200\" />\n                      <h3 class=\"text-xs text-gray-800 dark:text-gray-200\">\n                        Sort By\n                      </h3>\n                    </div>\n                    <button\n                      class=\"flex size-6 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-gray-600 focus:outline-none dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-300 -mt-1 -mr-1\"\n                      @click=\"close\"\n                    >\n                      <PhX class=\"size-4\" />\n                    </button>\n                  </div>\n\n                  <div class=\"flex flex-col gap-y-3\">\n                    <AppRadio\n                      v-model=\"sort\"\n                      label=\"Name ↑ (A-Z)\"\n                      value=\"value:asc\"\n                      name=\"sort\"\n                    />\n                    <AppRadio\n                      v-model=\"sort\"\n                      label=\"Name ↓ (Z-A)\"\n                      value=\"value:desc\"\n                      name=\"sort\"\n                    />\n                    <AppRadio\n                      v-model=\"sort\"\n                      label=\"Count ↑ (0-9)\"\n                      value=\"count:asc\"\n                      name=\"sort\"\n                    />\n                    <AppRadio\n                      v-model=\"sort\"\n                      label=\"Count ↓ (9-0)\"\n                      value=\"count:desc\"\n                      name=\"sort\"\n                    />\n                  </div>\n\n                  <div class=\"border-t border-gray-200 dark:border-gray-700\" />\n\n                  <div class=\"flex items-center gap-x-2\">\n                    <PhListChecks class=\"size-5 text-gray-800 dark:text-white\" />\n                    <h3 class=\"text-xs text-black dark:text-white\">\n                      Includes\n                    </h3>\n                  </div>\n\n                  <div class=\"flex flex-col gap-y-3\">\n                    <SwitchGroup\n                      v-for=\"(value, key) in includes\"\n                      :key=\"key\"\n                    >\n                      <div class=\"flex items-center justify-between\">\n                        <SwitchLabel class=\"flex items-center gap-x-1\">\n                          <AppBullet\n                            :size=\"3\"\n                            :color=\"getColor(key)\"\n                          />\n                          <span class=\"text-xs text-black dark:text-white\">{{ key.charAt(0).toUpperCase() + key.slice(1) }}</span>\n                        </SwitchLabel>\n                        <Switch\n                          v-model=\"includes[key]\"\n                          :class=\"value ? 'bg-black dark:bg-neutral-500' : 'bg-neutral-200 dark:bg-neutral-700'\"\n                          class=\"relative inline-flex h-4 w-7 items-center rounded-full transition-colors duration-150\"\n                        >\n                          <span class=\"sr-only\">{{ key }}</span>\n                          <span\n                            :class=\"value ? 'translate-x-3.5' : 'translate-x-0.5'\"\n                            class=\"inline-block size-3 rounded-full bg-white transition-transform duration-150\"\n                          />\n                        </Switch>\n                      </div>\n                    </SwitchGroup>\n                  </div>\n                </div>\n              </div>\n            </PopoverPanel>\n          </Transition>\n        </Popover>\n      </div>\n    </div>\n    <AppInfiniteScroll\n      ref=\"scrollRef\"\n      :limit=\"200\"\n      class=\"list-view flex h-full scroll-p-0.5 flex-col overflow-y-auto overflow-x-hidden py-1 text-xs\"\n      @scroll:end=\"paginate\"\n    >\n      <ul\n        role=\"listbox\"\n        aria-label=\"Filter options\"\n        class=\"w-full\"\n      >\n        <li\n          v-for=\"(item, key) in list\"\n          :key=\"item.id + key\"\n          role=\"option\"\n          :aria-selected=\"selected(item.key, item.value)\"\n          class=\"w-full\"\n        >\n          <label\n            :key=\"item.id + key\"\n            :for=\"item.id + key\"\n            :class=\"{'bg-neutral-100 dark:bg-neutral-900': selected(item.key, item.value)}\"\n            class=\"my-1 flex cursor-pointer place-items-end items-center px-3 py-2 text-gray-700 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-900 focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-gray-300 dark:focus-visible:ring-gray-600 focus-visible:ring-offset-0 rounded-md\"\n            tabindex=\"0\"\n            role=\"button\"\n            @keydown.enter=\"update(item)\"\n            @keydown.space.prevent=\"update(item)\"\n          >\n            <component\n              :is=\"getIcon(item)\"\n              v-tooltip.top=\"{ content: getTooltip(item) }\"\n              class=\"size-4 select-none focus:outline-none\"\n              tabindex=\"-1\"\n            />\n            <input\n              :id=\"item.id + key\"\n              type=\"checkbox\"\n              class=\"hidden\"\n              name=\"item\"\n              :value=\"item.value\"\n              :checked=\"selected(item.key, item.value)\"\n              @input=\"update(item)\"\n            >\n            <span class=\"truncate px-1\"> {{ item.label || item.value }} </span>\n            <span\n              class=\"ml-auto\"\n            >{{ item.count }}</span>\n          </label>\n        </li>\n      </ul>\n    </AppInfiniteScroll>\n  </div>\n</template>\n<script setup>\nimport {\n  Popover, PopoverButton, PopoverPanel, SwitchGroup, SwitchLabel,\n  Switch,\n} from '@headlessui/vue';\nimport {\n  computed, defineModel, onMounted, ref, onBeforeUnmount, watch,\n} from 'vue';\nimport AppRadio from '@/components/app/AppRadio.vue';\nimport AppBullet from '@/components/app/AppBullet.vue';\nimport AppInfiniteScroll from '@/components/app/AppInfiniteScroll.vue';\nimport PhListChecks from '~icons/ph/list-checks';\nimport PhArrowsDownUp from '~icons/ph/arrows-down-up';\nimport MaterialSymbolsLightCategorySearchOutline from '~icons/material-symbols-light/category-search-outline';\nimport PhHashStraightLight from '~icons/ph/hash-straight-light';\nimport PhGlobeSimpleLight from '~icons/ph/globe-simple-light';\nimport PhListMagnifyingGlassLight from '~icons/ph/list-magnifying-glass-light';\nimport PhX from '~icons/ph/x';\n\nconst emit = defineEmits(['update:modelValue', 'paginate']);\n\nconst sort = defineModel('sort', { type: String, required: true });\nconst includes = defineModel('includes', { type: Object, required: true });\nconst term = defineModel('term', { type: String, required: true });\n\nconst props = defineProps({\n  modelValue: {\n    type: Array,\n    required: true,\n  },\n  items: {\n    type: Array,\n    required: true,\n  },\n});\n\nconst iconMap = {\n  keyword: PhListMagnifyingGlassLight,\n  domain: PhGlobeSimpleLight,\n  tag: PhHashStraightLight,\n};\n\nconst tooltipMap = {\n  keyword: 'Filter by keywords',\n  domain: 'Filter by website',\n  tag: 'Filter by tag',\n};\n\nconst popoverButtonRef = ref(null);\nconst scrollRef = ref(null);\n\nconst getIcon = (item) => iconMap[item.key];\n\nconst getTooltip = (item) => tooltipMap[item.key];\n\nconst selected = (key, value) => props.modelValue.some((item) => item.key === key && item.value === value);\n\nconst list = computed(() => props.items);\n\nconst paginate = (skip) => {\n  emit('paginate', skip);\n};\n\nconst getColor = (key) => {\n  switch (key) {\n    case 'domain':\n      return 'yellow';\n    case 'tag':\n      return 'gray';\n    case 'keyword':\n      return 'green';\n    case 'locale':\n      return 'cyan';\n    case 'type':\n      return 'indigo';\n    default:\n      return 'stone';\n  }\n};\n\nconst update = (item) => {\n  const updatedModelValue = [...props.modelValue];\n  const index = updatedModelValue.findIndex((f) => f.key === item.key && f.value === item.value);\n  if (index !== -1) {\n    updatedModelValue.splice(index, 1);\n  } else {\n    updatedModelValue.push({ key: item.key, value: item.value, label: item.label || item.value });\n  }\n  emit('update:modelValue', updatedModelValue);\n};\n\nconst close = () => {\n  popoverButtonRef.value.el.click();\n};\n\nconst popoverKbd = (event) => {\n  if ((event.metaKey || event.ctrlKey) && event.key === '/') {\n    event.preventDefault();\n    close();\n  }\n};\n\nonMounted(() => {\n  document.addEventListener('keydown', popoverKbd);\n});\n\nonBeforeUnmount(() => {\n  document.removeEventListener('keydown', popoverKbd);\n});\n\nwatch([sort, includes, term], () => {\n  scrollRef.value.scrollUp();\n});\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/BookmarkFavicon.vue",
    "content": "<template>\n  <div>\n    <img\n      v-if=\"faviconUrl\"\n      v-show=\"loaded\"\n      :key=\"faviconUrl\"\n      class=\"size-full\"\n      :src=\"faviconUrl\"\n      alt=\"favicon\"\n      @load=\"loaded = true\"\n      @error=\"handleError\"\n    >\n\n    <PhGlobeSimpleLight\n      v-if=\"!loaded\"\n      class=\"size-4 text-black dark:text-white\"\n    />\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed } from 'vue';\nimport PhGlobeSimpleLight from '~icons/ph/globe-simple-light';\n\nconst props = defineProps({\n  bookmark: {\n    type: Object,\n    required: true,\n  },\n});\n\nconst loaded = ref(false);\nconst originalFailed = ref(false);\nconst fallbackFailed = ref(false);\n\nconst faviconUrl = computed(() => {\n  if (props.bookmark.favicon && !originalFailed.value) {\n    return props.bookmark.favicon;\n  }\n\n  if (!fallbackFailed.value && props.bookmark.domain) {\n    return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(props.bookmark.domain)}.ico`;\n  }\n\n  return null;\n});\n\nconst handleError = () => {\n  loaded.value = false;\n\n  if (!originalFailed.value) {\n    originalFailed.value = true;\n  } else {\n    fallbackFailed.value = true;\n  }\n};\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/BookmarkForm.vue",
    "content": "<template>\n  <form\n    class=\"flex flex-col gap-y-3\"\n    @submit.prevent=\"submit\"\n  >\n    <label\n      for=\"title\"\n      class=\"relative\"\n    >\n      <input\n        id=\"title\"\n        v-model=\"bookmark.title\"\n        type=\"text\"\n        placeholder=\"Page title\"\n        class=\"h-9 w-full rounded-md border-gray-200 pl-10 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 focus:dark:border-neutral-700\"\n      >\n      <div class=\"pointer-events-none absolute inset-y-0 left-0 grid w-10 place-content-center text-gray-700\">\n        <BookmarkFavicon\n          :bookmark=\"bookmark\"\n          class=\"size-5 fill-black\"\n        />\n      </div>\n    </label>\n    <Treeselect\n      v-model=\"bookmark.folderId\"\n      placeholder=\"\"\n      :before-clear-all=\"onBeforeClearAll\"\n      :always-open=\"false\"\n      :default-expand-level=\"Infinity\"\n      :options=\"folders\"\n    />\n    <AppTagInput\n      v-model=\"bookmark.tags\"\n      class=\"relative\"\n      :max=\"5\"\n      :suggestions=\"tags\"\n      placeholder=\"Tag it and press enter 🏷️\"\n    />\n    <AppButton class=\"w-full\">\n      Save bookmark\n    </AppButton>\n  </form>\n</template>\n\n<script setup>\nimport { ref, watch } from 'vue';\nimport Treeselect from '@zanmato/vue3-treeselect';\nimport AppTagInput from '@/components/app/AppTagInput.vue';\nimport BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';\nimport AppButton from '@/components/app/AppButton.vue';\nimport { joinTitleAndTags } from '@/services/tags';\n\nconst props = defineProps({\n  bookmark: {\n    type: Object,\n    required: true,\n  },\n  folders: {\n    type: Array,\n    required: true,\n    default: () => [],\n  },\n  tags: {\n    type: Array,\n    required: true,\n    default: () => [],\n  },\n});\n\nconst bookmark = ref({ ...props.bookmark });\nconst emit = defineEmits(['onSubmit']);\n\nconst findLabelById = (data, id) => {\n  for (const item of data) {\n    if (item.id === id) {\n      return item.label;\n    }\n    if (item.children) {\n      const result = findLabelById(item.children, id);\n      if (result) {\n        return result;\n      }\n    }\n  }\n  return null;\n};\n\nconst onBeforeClearAll = () => {\n  bookmark.value.folderId = 1;\n};\n\nconst submit = () => {\n  const value = JSON.parse(JSON.stringify(bookmark.value));\n  emit('onSubmit', {\n    browserTitle: joinTitleAndTags(value.title, value.tags),\n    title: value.title,\n    folderName: value.folderName,\n    folderId: value.folderId,\n    tags: value.tags,\n    id: value.id,\n  });\n};\n\nwatch(() => bookmark.value.folderId, (newId) => {\n  bookmark.value.folderName = findLabelById(props.folders, newId);\n}, { immediate: true });\n\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/BookmarkLayout.vue",
    "content": "<template>\n  <masonry\n    v-if=\"displayType === 'masonry'\"\n    :resolve-slot=\"true\"\n    :cols=\"{ 5120: 12, 3840: 10, 2560: 7, 1920: 4, 1280: 4, 992: 3, 768: 2, 576: 1 }\"\n    :gutter=\"20\"\n    class=\"card-container\"\n  >\n    <slot />\n  </masonry>\n  <TransitionGroup\n    v-else\n    tag=\"div\"\n    appear\n    enter-active-class=\"transition-opacity duration-200 ease-out\"\n    enter-from-class=\"opacity-0\"\n    enter-to-class=\"opacity-100\"\n    :class=\"layoutClasses\"\n  >\n    <slot />\n  </TransitionGroup>\n</template>\n<script setup>\nimport { computed } from 'vue';\n\nconst props = defineProps({\n  displayType: {\n    type: String,\n    required: true,\n    default: 'masonry',\n    validator: (value) => ['masonry', 'card', 'list'].includes(value),\n  },\n});\n\nconst LAYOUT_CLASSES = {\n  masonry: '',\n  card: 'grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',\n  list: 'grid gap-3 grid-cols-1',\n};\n\nconst layoutClasses = computed(() => LAYOUT_CLASSES[props.displayType] || LAYOUT_CLASSES.masonry);\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/BookmarksSync.vue",
    "content": "<template>\n  <div>\n    <TransitionRoot\n      appear\n      :show=\"isOpen\"\n      as=\"template\"\n    >\n      <Dialog\n        as=\"div\"\n        class=\"relative z-10\"\n        @close=\"close\"\n      >\n        <TransitionChild\n          as=\"template\"\n          enter=\"duration-300 ease-out\"\n          enter-from=\"opacity-0\"\n          enter-to=\"opacity-100\"\n          leave=\"duration-200 ease-in\"\n          leave-from=\"opacity-100\"\n          leave-to=\"opacity-0\"\n        >\n          <div class=\"fixed inset-0 bg-[rgba(255,_255,_255,_0.19)] backdrop-blur-[13px] backdrop-saturate-[200%]\" />\n        </TransitionChild>\n        <div class=\"fixed inset-0 overflow-y-auto\">\n          <div\n            class=\"flex min-h-full items-center justify-center p-4 text-center\"\n          >\n            <TransitionChild\n              as=\"template\"\n              enter=\"duration-300 ease-out\"\n              enter-from=\"opacity-0 scale-95\"\n              enter-to=\"opacity-100 scale-100\"\n              leave=\"duration-200 ease-in\"\n              leave-from=\"opacity-100 scale-100\"\n              leave-to=\"opacity-0 scale-95\"\n            >\n              <DialogPanel\n                class=\"w-full max-w-md overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-black\"\n              >\n                <DialogTitle\n                  as=\"h3\"\n                  class=\"flex items-center text-lg font-medium leading-6 text-gray-900 dark:text-white\"\n                >\n                  <PixelarticonsHeart class=\"mr-2\" />\n                  Scanning your bookmarks..\n                </DialogTitle>\n                <div class=\"mt-2\">\n                  <p class=\"py-1 text-sm text-black dark:text-white\">\n                    The app is scanning your bookmarks and gathering information about the pages to make everything run smoother and faster.\n                    This process may take a little time depending on how many bookmarks you have, your network and device performance.\n                  </p>\n                  <p class=\"py-4 text-sm text-black dark:text-white\">\n                    Thank you for your patience!\n                  </p>\n                  <AppProgress\n                    :progress=\"progress\"\n                  />\n                </div>\n                <div class=\"mt-4 flex justify-end\">\n                  <AppButton @click=\"close\">\n                    OK\n                  </AppButton>\n                </div>\n              </DialogPanel>\n            </TransitionChild>\n          </div>\n        </div>\n      </Dialog>\n    </TransitionRoot>\n    <div\n      v-if=\"!isOpen && isSyncing\"\n      class=\"fixed bottom-4 right-4 z-50\"\n    >\n      <button\n        class=\"relative size-16 cursor-pointer rounded-full bg-black from-pink-500 via-blue-500 to-green-500 font-mono text-xs text-white shadow-[0_0_0_4px_rgba(180,160,255,0.253)] transition-all duration-300 after:absolute after:inset-0 after:-z-10 after:rounded-full after:bg-gradient-to-r after:opacity-60 after:blur-md hover:shadow-[0_0_20px_rgba(180,160,255,0.5)] hover:after:opacity-100\"\n        type=\"button\"\n        aria-label=\"Sync progress\"\n        title=\"Sync progress\"\n        tabindex=\"0\"\n        @click=\"open\"\n      >\n        <span class=\"text-sm text-white\">\n          <NumberFlow :value=\"progress\" />%\n        </span>\n      </button>\n    </div>\n  </div>\n</template>\n<script setup>\nimport NumberFlow from '@number-flow/vue';\nimport { onMounted, ref, defineEmits } from 'vue';\nimport {\n  TransitionRoot,\n  TransitionChild,\n  Dialog,\n  DialogPanel,\n  DialogTitle,\n} from '@headlessui/vue';\nimport AppProgress from '@/components/app/AppProgress.vue';\nimport AppButton from '@/components/app/AppButton.vue';\nimport PixelarticonsHeart from '~icons/pixelarticons/heart';\n\nconst status = ref(false);\nconst progress = ref(0);\nconst isOpen = ref(false);\nconst isSyncing = ref(false);\n\nconst close = () => { isOpen.value = false; };\nconst open = () => { isOpen.value = true; };\nconst emit = defineEmits(['onSync']);\n\nonMounted(async () => {\n  const storageData = await browser.storage.session.get(['status', 'progress']);\n  status.value = storageData.status ?? false;\n  progress.value = storageData.progress ?? 0;\n\n  isSyncing.value = !status.value && progress.value > 0 && progress.value < 100;\n\n  isOpen.value = isSyncing.value;\n});\n\nbrowser.runtime.onMessage.addListener(async (message) => {\n  if (message.action === 'sync') {\n    const wasSyncing = isSyncing.value;\n    progress.value = message.data.progress;\n    status.value = message.data.progress >= 100;\n    isSyncing.value = !status.value && message.data.progress > 0 && message.data.progress < 100;\n    if (isSyncing.value && !wasSyncing) {\n      isOpen.value = true;\n    } else if (message.data.progress >= 100) {\n      isOpen.value = false;\n    }\n    emit('onSync', message.data);\n    console.warn('BookmarksSync', message.data);\n  }\n});\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/CommandPalette.vue",
    "content": "<template>\n  <TransitionRoot\n    appear\n    :show=\"isOpen\"\n    as=\"template\"\n  >\n    <Dialog\n      as=\"div\"\n      class=\"relative z-10\"\n      @close=\"close\"\n    >\n      <TransitionChild\n        as=\"template\"\n        enter=\"duration-300 ease-out\"\n        enter-from=\"opacity-0\"\n        enter-to=\"opacity-100\"\n        leave=\"duration-200 ease-in\"\n        leave-from=\"opacity-100\"\n        leave-to=\"opacity-0\"\n      >\n        <div class=\"fixed inset-0 bg-transparent backdrop-blur\" />\n      </TransitionChild>\n      <div class=\"fixed inset-0 overflow-y-auto\">\n        <div class=\"flex min-h-full items-center justify-center p-4 text-center\">\n          <TransitionChild\n            as=\"template\"\n            enter=\"duration-300 ease-out\"\n            enter-from=\"opacity-0 scale-95\"\n            enter-to=\"opacity-100 scale-100\"\n            leave=\"duration-200 ease-in\"\n            leave-from=\"opacity-100 scale-100\"\n            leave-to=\"opacity-0 scale-95\"\n          >\n            <DialogPanel\n              class=\"flex h-screen max-h-[32rem] w-full max-w-md overflow-hidden rounded-lg border bg-white/60 shadow-lg backdrop-blur-lg transition-all dark:border-neutral-800 dark:bg-black/40 dark:text-white focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0\"\n            >\n              <div\n                class=\"flex w-full flex-col items-start justify-between h-full\"\n              >\n                <div class=\"flex w-full flex-col flex-1 min-h-0\">\n                  <div class=\"flex w-full items-center px-4 py-3 text-gray-700\">\n                    <span class=\"mr-2 text-xl text-gray-800 dark:text-gray-200\">\n                      <PhMagnifyingGlassLight />\n                    </span>\n                    <input\n                      ref=\"input\"\n                      v-model=\"searchTerm\"\n                      type=\"text\"\n                      aria-label=\"Search command palette\"\n                      class=\"flex-1 border-none bg-transparent text-sm outline-none ring-0 placeholder:text-gray-600 focus:border-0 focus:border-none focus:ring-0 dark:text-gray-100 dark:placeholder:text-gray-400\"\n                      placeholder=\"Search...\"\n                      autocomplete=\"off\"\n                      autocorrect=\"off\"\n                      spellcheck=\"false\"\n                      @blur=\"refocusInput\"\n                      @keydown.down.prevent=\"navigateDown\"\n                      @keydown.up.prevent=\"navigateUp\"\n                      @keydown.enter.prevent=\"selectActiveItem\"\n                    >\n                  </div>\n\n                  <AppInfiniteScroll\n                    class=\"list-view w-full flex-1 overflow-y-auto p-1\"\n                    :limit=\"100\"\n                    @scroll:end=\"paginate\"\n                  >\n                    <div\n                      v-if=\"isLoading\"\n                      class=\"flex min-h-full items-center justify-center p-4\"\n                    >\n                      <AppSpinner class=\"size-6\" />\n                    </div>\n                    <ul\n                      v-else-if=\"items.length\"\n                      class=\"gap-y-2\"\n                      role=\"menu\"\n                    >\n                      <li\n                        v-for=\"(item, index) in items\"\n                        :key=\"index\"\n                        ref=\"item\"\n                        class=\"flex cursor-pointer items-center gap-x-2 rounded-md p-3 transition-colors duration-200 hover:bg-black/5 hover:dark:bg-white/10 focus:outline-none focus-visible:ring-1 focus-visible:ring-gray-400 dark:focus-visible:ring-gray-500\"\n                        :class=\"{\n                          'bg-black/10 dark:bg-white/10': activeIndex === index\n                        }\"\n                        role=\"menuitem\"\n                        tabindex=\"0\"\n                        @click=\"handleCommandSelect(item)\"\n                        @keydown.enter=\"handleCommandSelect(item)\"\n                        @keydown.arrow-up.prevent=\"navigateUp\"\n                        @keydown.arrow-down.prevent=\"navigateDown\"\n                        @focus=\"activeIndex = index\"\n                      >\n                        <div\n                          class=\"flex size-8 items-center justify-center rounded-lg bg-gradient-to-br from-white to-gray-100 backdrop-blur-sm dark:from-black/80 dark:to-black/80\"\n                        >\n                          <component\n                            :is=\"item.icon\"\n                            class=\"size-4 text-gray-700 dark:text-gray-200\"\n                          />\n                        </div>\n                        <span class=\"text-sm text-gray-800 dark:text-gray-100\">{{ item.value }}</span>\n                      </li>\n                    </ul>\n                    <div\n                      v-else\n                      class=\"flex min-h-full items-center justify-center p-4 text-sm font-thin text-gray-800 dark:text-gray-300\"\n                    >\n                      🔍 No results found.\n                    </div>\n                  </AppInfiniteScroll>\n                </div>\n                <div\n                  class=\"w-full border-t border-gray-200 bg-gray-50 px-4 py-3 backdrop-blur-sm dark:border-white/10 dark:bg-white/5\"\n                >\n                  <div class=\"flex items-center justify-between\">\n                    <div class=\"flex items-center gap-2\">\n                      <kbd\n                        class=\"inline-flex h-6 min-w-[24px] items-center justify-center rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 shadow-sm dark:border-transparent dark:bg-black dark:text-gray-200\"\n                      >↑↓</kbd>\n                      <span class=\"text-xs text-gray-600 dark:text-gray-300\">navigate</span>\n                    </div>\n                    <div class=\"flex items-center gap-4\">\n                      <div class=\"flex items-center gap-2\">\n                        <kbd\n                          class=\"inline-flex h-6 min-w-[24px] items-center justify-center rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 shadow-sm dark:border-transparent dark:bg-black dark:text-gray-200\"\n                        >↵</kbd>\n                        <span class=\"text-xs text-gray-600 dark:text-gray-300\">select</span>\n                      </div>\n                      <div class=\"flex items-center gap-2\">\n                        <kbd\n                          class=\"inline-flex h-6 min-w-[24px] items-center justify-center rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 shadow-sm dark:border-transparent dark:bg-black dark:text-gray-200\"\n                        >esc</kbd>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </DialogPanel>\n          </TransitionChild>\n        </div>\n      </div>\n    </Dialog>\n  </TransitionRoot>\n</template>\n\n<script setup>\nimport { ref, useTemplateRef, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';\nimport {\n  Dialog,\n  TransitionRoot,\n  TransitionChild,\n  DialogPanel,\n} from '@headlessui/vue';\nimport AttributeStorage from '@/storage/attribute';\nimport AppInfiniteScroll from '@/components/app/AppInfiniteScroll.vue';\nimport AppSpinner from '@/components/app/AppSpinner.vue';\nimport { useDebounceFn } from '@vueuse/core';\n\nimport PhMagnifyingGlassLight from '~icons/ph/magnifying-glass-light';\nimport PhHashStraightLight from '~icons/ph/hash-straight-light';\nimport PhGlobeSimpleLight from '~icons/ph/globe-simple-light';\nimport PhListMagnifyingGlassLight from '~icons/ph/list-magnifying-glass-light';\n\nconst attributeStorage = new AttributeStorage();\nconst emit = defineEmits(['onSelected', 'onVisibilityToggle']);\n\nconst isOpen = ref(false);\nconst searchTerm = ref('');\nconst activeIndex = ref(-1);\nconst items = ref([]);\nconst itemRef = useTemplateRef('item');\nconst inputRef = useTemplateRef('input');\nconst isLoading = ref(false);\n\nconst iconMap = {\n  tag: PhHashStraightLight,\n  domain: PhGlobeSimpleLight,\n  keyword: PhListMagnifyingGlassLight,\n};\n\nconst paginate = async (skip) => {\n  try {\n    const results = await attributeStorage.search(\n      { tag: true, domain: true, keyword: true },\n      'value',\n      'asc',\n      searchTerm.value,\n      skip,\n      100,\n    );\n    const resultsWithIcons = results.map((item) => ({\n      ...item,\n      icon: iconMap[item.key],\n    }));\n    items.value.push(...resultsWithIcons);\n  } catch (e) {\n    console.error(e);\n  }\n};\n\nconst scrollToActive = (newIndex) => {\n  activeIndex.value = newIndex;\n  const el = itemRef.value[newIndex];\n  if (el) {\n    el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });\n  }\n};\n\nconst navigateDown = () => {\n  const nextIndex = activeIndex.value + 1;\n  if (nextIndex < items.value.length) {\n    scrollToActive(nextIndex);\n  }\n};\n\nconst navigateUp = () => {\n  const prevIndex = activeIndex.value - 1;\n  if (prevIndex >= 0) {\n    scrollToActive(prevIndex);\n  }\n};\n\nconst performSearch = useDebounceFn(async () => {\n  isLoading.value = true;\n  try {\n    const results = await attributeStorage.search(\n      { tag: true, domain: true, keyword: true },\n      'value',\n      'asc',\n      searchTerm.value,\n      0,\n      100,\n    );\n    items.value = results.map((item) => ({\n      ...item,\n      icon: iconMap[item.key],\n    }));\n    activeIndex.value = 0;\n  } catch (e) {\n    console.error('Search error:', e);\n    items.value = [];\n  } finally {\n    isLoading.value = false;\n  }\n}, 100);\n\nconst close = () => {\n  isOpen.value = false;\n  searchTerm.value = '';\n  activeIndex.value = -1;\n  items.value = [];\n  emit('onVisibilityToggle', false);\n};\n\nconst handleCommandSelect = (selectedItem) => {\n  emit('onSelected', [{ key: selectedItem.key, value: selectedItem.value }]);\n  close();\n};\n\nconst selectActiveItem = () => {\n  if (activeIndex.value >= 0 && activeIndex.value < items.value.length) {\n    handleCommandSelect(items.value[activeIndex.value]);\n  }\n};\n\nconst toggle = () => {\n  isOpen.value = !isOpen.value;\n  if (isOpen.value) {\n    performSearch();\n  }\n  emit('onVisibilityToggle', isOpen.value);\n};\n\nconst hotKey = (event) => {\n  if (event.altKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyK') {\n    event.preventDefault();\n    event.stopPropagation();\n    toggle();\n  }\n};\n\nconst refocusInput = () => setTimeout(() => { inputRef.value?.focus(); }, 10);\n\nwatch(searchTerm, () => {\n  if (isOpen.value) {\n    performSearch();\n  }\n});\n\nwatch(isOpen, (open) => {\n  if (open) {\n    performSearch();\n    nextTick(() => {\n      inputRef.value?.focus();\n    });\n  }\n});\n\ndefineExpose({ toggle });\n\nonMounted(() => { document.addEventListener('keydown', hotKey); });\nonBeforeUnmount(() => { document.removeEventListener('keydown', hotKey); });\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/DatePicker.vue",
    "content": "<template>\n  <div\n    ref=\"rootRef\"\n    class=\"relative\"\n  >\n    <button\n      v-tooltip.bottom=\"{ content: 'Date filter' }\"\n      class=\"inline-flex size-9 items-center justify-center rounded-md border border-gray-400/30 bg-white text-gray-700 shadow-sm hover:bg-gray-50 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800\"\n      @click=\"toggleCalendar\"\n    >\n      <IconoirCalendar class=\"size-5 text-gray-700 dark:text-neutral-400\" />\n    </button>\n    <Transition\n      enter-active-class=\"transition duration-200 ease-out\"\n      enter-from-class=\"translate-y-1 opacity-0\"\n      enter-to-class=\"translate-y-0 opacity-100\"\n      leave-active-class=\"transition duration-150 ease-in\"\n      leave-from-class=\"translate-y-0 opacity-100\"\n      leave-to-class=\"translate-y-1 opacity-0\"\n    >\n      <div\n        v-if=\"isOpen\"\n        class=\"absolute right-0 z-50 mt-2 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 dark:bg-neutral-900\"\n      >\n        <VueDatePicker\n          v-model=\"selectedDate\"\n          :enable-time-picker=\"false\"\n          :dark=\"isDarkMode\"\n          :range=\"true\"\n          :inline=\"true\"\n          :auto-apply=\"true\"\n          @update:model-value=\"onDateSelected\"\n        />\n      </div>\n    </Transition>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, onBeforeUnmount } from 'vue';\nimport { useDark } from '@vueuse/core';\nimport VueDatePicker from '@vuepic/vue-datepicker';\nimport IconoirCalendar from '~icons/iconoir/calendar';\n\nconst props = defineProps({\n  modelValue: {\n    type: [Date, String, Array, null],\n    default: null,\n  },\n});\n\nconst emit = defineEmits(['update:modelValue']);\n\nconst isOpen = ref(false);\nconst rootRef = ref(null);\n\nconst selectedDate = computed({\n  get: () => props.modelValue,\n  set: (value) => emit('update:modelValue', value),\n});\n\nconst isDarkMode = useDark();\n\nconst toggleCalendar = () => {\n  isOpen.value = !isOpen.value;\n};\n\nconst onDateSelected = (value) => {\n  selectedDate.value = value;\n  isOpen.value = false;\n};\n\nconst handleClickOutside = (event) => {\n  if (isOpen.value && rootRef.value && !rootRef.value.contains(event.target)) {\n    isOpen.value = false;\n  }\n};\n\nonMounted(() => {\n  document.addEventListener('mousedown', handleClickOutside);\n});\n\nonBeforeUnmount(() => {\n  document.removeEventListener('mousedown', handleClickOutside);\n});\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/FolderTree.vue",
    "content": "<template>\n  <div class=\"flex h-full flex-col overflow-hidden\">\n    <ul\n      class=\"space-y-0.5 overflow-y-auto py-1\"\n      role=\"tree\"\n      aria-label=\"Bookmark folders\"\n    >\n      <FolderTreeItem\n        v-for=\"folder in folders\"\n        :key=\"`${folder.id}-${folder.label}`\"\n        :folder=\"folder\"\n        :selected-id=\"selectedFolderId\"\n        :level=\"0\"\n        @select=\"selectFolder\"\n      />\n    </ul>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, watch } from 'vue';\nimport FolderTreeItem from '@/ext/browser/components/FolderTreeItem.vue';\n\nconst props = defineProps({\n  folders: {\n    type: Array,\n    default: () => [],\n    validator: (value) => Array.isArray(value),\n  },\n  modelValue: {\n    type: Array,\n    required: true,\n    validator: (value) => Array.isArray(value),\n  },\n});\n\nconst emit = defineEmits(['update:modelValue']);\n\nconst selectedFolderId = ref(null);\n\nconst folderFilter = computed(() => props.modelValue.find((item) => item.key === 'folder'));\n\nconst selectFolder = ({ id, label }) => {\n  if (selectedFolderId.value === id) {\n    selectedFolderId.value = null;\n    emit('update:modelValue', props.modelValue.filter((item) => item.key !== 'folder'));\n  } else {\n    selectedFolderId.value = id;\n    const filtered = props.modelValue.filter((item) => item.key !== 'folder');\n    filtered.push({ key: 'folder', value: id, label });\n    emit('update:modelValue', filtered);\n  }\n};\n\nwatch(\n  () => folderFilter.value?.value,\n  (newValue) => {\n    selectedFolderId.value = newValue || null;\n  },\n  { immediate: true },\n);\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/FolderTreeItem.vue",
    "content": "<template>\n  <li\n    class=\"select-none\"\n    role=\"treeitem\"\n    :aria-expanded=\"hasChildren ? isExpanded : undefined\"\n    :aria-selected=\"isSelected\"\n  >\n    <div\n      role=\"button\"\n      tabindex=\"0\"\n      class=\"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1.5 text-gray-700 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-900 focus:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 dark:focus-visible:ring-gray-600\"\n      :class=\"{ 'bg-neutral-100 dark:bg-neutral-900': isSelected }\"\n      :aria-label=\"`${folder.label}${folder.count ? `, ${folder.count} bookmarks` : ''}`\"\n      @click=\"select\"\n      @keydown.enter=\"select\"\n      @keydown.space.prevent=\"select\"\n    >\n      <button\n        v-if=\"hasChildren\"\n        class=\"flex size-4 items-center justify-center text-gray-400 hover:text-gray-600 dark:text-neutral-500 dark:hover:text-neutral-300 focus:outline-none\"\n        :aria-label=\"isExpanded ? 'Collapse folder' : 'Expand folder'\"\n        @click.stop=\"toggle\"\n        @keydown.enter.stop=\"toggle\"\n        @keydown.space.stop.prevent=\"toggle\"\n      >\n        <PhCaretRight\n          class=\"size-3 transition-transform duration-150\"\n          :class=\"{ 'rotate-90': isExpanded }\"\n        />\n      </button>\n      <span\n        v-else\n        class=\"size-4\"\n        aria-hidden=\"true\"\n      />\n      <PhFolderSimpleLight class=\"size-4 shrink-0\" />\n      <span class=\"truncate text-xs\">{{ folder.label }}</span>\n      <span\n        v-if=\"folder.count\"\n        class=\"ml-auto text-xs text-gray-400 dark:text-neutral-600\"\n        aria-label=\"bookmark count\"\n      >{{ folder.count }}</span>\n    </div>\n    <ul\n      v-if=\"hasChildren && isExpanded\"\n      class=\"ml-3 border-l border-gray-200 dark:border-neutral-700\"\n      role=\"group\"\n    >\n      <FolderTreeItem\n        v-for=\"child in folder.children\"\n        :key=\"`${child.id}-${child.label}`\"\n        :folder=\"child\"\n        :selected-id=\"selectedId\"\n        :level=\"level + 1\"\n        @select=\"$emit('select', $event)\"\n      />\n    </ul>\n  </li>\n</template>\n\n<script setup>\nimport { ref, computed, watch } from 'vue';\nimport PhCaretRight from '~icons/ph/caret-right';\nimport PhFolderSimpleLight from '~icons/ph/folder-simple-light';\n\nconst props = defineProps({\n  folder: {\n    type: Object,\n    required: true,\n    validator: (value) => value && typeof value === 'object' && value.id && value.label,\n  },\n  selectedId: {\n    type: String,\n    default: null,\n  },\n  level: {\n    type: Number,\n    default: 0,\n    validator: (value) => typeof value === 'number' && value >= 0,\n  },\n});\n\nconst emit = defineEmits(['select']);\n\nconst hasChildren = computed(() => Boolean(props.folder.children?.length));\n\nconst hasSelectedChild = computed(() => {\n  if (!hasChildren.value || !props.selectedId) return false;\n\n  const checkChildren = (children) => {\n    for (const child of children) {\n      if (child.id === props.selectedId) return true;\n      if (child.children && checkChildren(child.children)) return true;\n    }\n    return false;\n  };\n\n  return checkChildren(props.folder.children);\n});\n\nconst isExpanded = ref(props.level < 2 || hasSelectedChild.value);\n\nconst isSelected = computed(() => props.selectedId === props.folder.id);\n\nwatch(\n  () => hasSelectedChild.value,\n  (hasSelected) => {\n    if (hasSelected) {\n      isExpanded.value = true;\n    }\n  },\n  { immediate: true },\n);\n\nconst toggle = () => {\n  isExpanded.value = !isExpanded.value;\n};\n\nconst select = () => {\n  emit('select', { id: props.folder.id, label: props.folder.label });\n};\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/SearchTerm.vue",
    "content": "<template>\n  <div\n    class=\"flex h-9 min-h-9 w-full rounded-md border border-gray-200 bg-white px-2 py-1 shadow-sm focus-within:border-gray-300 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white focus-within:dark:border-neutral-700\"\n  >\n    <div class=\"flex min-w-0 flex-1 items-center gap-x-1 overflow-x-auto\">\n      <ul class=\"flex h-full items-center gap-x-1 whitespace-nowrap\">\n        <li\n          v-for=\"(tag, tagKey) in modelValue\"\n          :key=\"tagKey\"\n          class=\"flex items-center\"\n        >\n          <AppBadge\n            closable\n            :color=\"getColor(tag.key)\"\n            @on-close=\"onClose(tag.key, tag.value)\"\n          >\n            <component\n              :is=\"getIcon(tag.key)\"\n              class=\"mr-1 size-4\"\n            />\n            {{ tag.label || tag.value }}\n          </AppBadge>\n        </li>\n      </ul>\n      <input\n        ref=\"inputRef\"\n        v-model=\"term\"\n        name=\"term\"\n        type=\"text\"\n        aria-label=\"Search\"\n        autocomplete=\"off\"\n        autocorrect=\"off\"\n        spellcheck=\"false\"\n        maxlength=\"25\"\n        :placeholder=\"placeholder\"\n        class=\"min-w-max flex-1 appearance-none border-0 bg-transparent px-1 py-0 text-xs placeholder:text-xs focus:outline-none focus:ring-0\"\n        @keydown.enter=\"add\"\n        @keydown.tab.prevent=\"add\"\n        @keydown.delete=\"removeLast\"\n      >\n    </div>\n    <div class=\"flex flex-shrink-0 flex-wrap items-center gap-x-1 text-xs text-gray-400 dark:text-neutral-600\">\n      <button\n        class=\"m-0 inline-flex appearance-none items-center gap-x-1 border-none bg-transparent p-0\"\n        @click=\"handleCommandPallete\"\n      >\n        <kbd class=\"inline-flex size-6 items-center justify-center rounded-md border border-gray-200 bg-white font-mono text-lg shadow-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200\">\n          ⌥\n        </kbd>\n        <kbd class=\"inline-flex size-6 items-center justify-center rounded-md border border-gray-200 bg-white font-mono text-xs shadow-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200\">\n          K\n        </kbd>\n      </button>\n    </div>\n    <CommandPalette\n      ref=\"cmd\"\n      @on-visibility-toggle=\"cmdToggle\"\n      @on-selected=\"emit('update:modelValue', [...modelValue, ...$event.filter(n => !modelValue.some(e => e.key === n.key && e.value === n.value))])\"\n    />\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed } from 'vue';\nimport AppBadge from '@/components/app/AppBadge.vue';\nimport CommandPalette from '@/ext/browser/components/CommandPalette.vue';\n\nimport PhHashStraightLight from '~icons/ph/hash-straight-light';\nimport PhGlobeSimpleLight from '~icons/ph/globe-simple-light';\nimport PhListMagnifyingGlassLight from '~icons/ph/list-magnifying-glass-light';\nimport PhFolderSimpleLight from '~icons/ph/folder-simple-light';\nimport PhMagnifyingGlassLight from '~icons/ph/magnifying-glass-light';\nimport MdiIdentifier from '~icons/mdi/identifier';\nimport PhCalendarBlank from '~icons/ph/calendar-blank';\n\nconst props = defineProps({\n  placeholder: {\n    type: String,\n    default: 'Search: tag:important domain:example.com',\n  },\n  modelValue: {\n    type: Array,\n    default: () => [],\n  },\n});\n\nconst term = ref('');\nconst inputRef = ref(null);\nconst cmd = ref(null);\nconst emit = defineEmits(['update:modelValue']);\n\nconst removeLast = () => {\n  if (term.value) return;\n  emit('update:modelValue', props.modelValue.slice(0, -1));\n};\n\nconst add = () => {\n  if (!term.value) return;\n  const [key, value] = term.value.split(':');\n  const validKeys = ['tag', 'keyword', 'domain', 'folder', 'id'];\n\n  if (validKeys.includes(key) && value) {\n    emit('update:modelValue', [...props.modelValue, { key, value }]);\n  } else {\n    const updatedValue = props.modelValue.filter((item) => item.key !== 'term');\n    updatedValue.push({ key: 'term', value: term.value });\n    emit('update:modelValue', updatedValue);\n  }\n  term.value = '';\n};\n\nconst iconMap = computed(() => ({\n  folder: PhFolderSimpleLight,\n  keyword: PhListMagnifyingGlassLight,\n  tag: PhHashStraightLight,\n  domain: PhGlobeSimpleLight,\n  id: MdiIdentifier,\n  dateAdded: PhCalendarBlank,\n  default: PhMagnifyingGlassLight,\n}));\n\nconst getIcon = (key) => iconMap.value[key] || iconMap.value.default;\nfunction getColor(key) {\n  switch (key) {\n    case 'dateAdded':\n      return 'cyan';\n    case 'folder':\n      return 'purple';\n    case 'keyword':\n      return 'green';\n    case 'tag':\n      return 'gray';\n    case 'domain':\n      return 'yellow';\n    case 'id':\n      return 'indigo';\n    default:\n      return 'pink';\n  }\n}\n\nconst focus = () => { inputRef.value.focus(); };\nconst handleCommandPallete = () => { cmd.value.toggle(); };\n\nconst onClose = (key, value) => {\n  const data = props.modelValue.filter((item) => !(item.key === key && item.value === value));\n  emit('update:modelValue', data);\n};\n\nconst cmdToggle = (status) => {\n  if (status === false) {\n    setTimeout(() => focus(), 500);\n  }\n};\n\ndefineExpose({ focus });\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/SortDirection.vue",
    "content": "<template>\n  <div class=\"relative\">\n    <button\n      class=\"inline-flex size-9 items-center justify-center rounded-md border-1 border-gray-400/30 bg-white text-gray-700 shadow-sm hover:bg-gray-50 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800\"\n      @click=\"toggleSort\"\n    >\n      <component\n        :is=\"sortIcon\"\n        class=\"size-5 cursor-pointer text-gray-700 dark:text-neutral-400\"\n      />\n    </button>\n  </div>\n</template>\n\n<script setup>\nimport { computed } from 'vue';\nimport SolarSortFromBottomToTopLineDuotone from '~icons/solar/sort-from-bottom-to-top-line-duotone';\nimport SolarSortFromTopToBottomLineDuotone from '~icons/solar/sort-from-top-to-bottom-line-duotone';\n\nconst props = defineProps({\n  modelValue: {\n    type: String,\n    default: 'desc',\n    required: true,\n  },\n});\n\nconst emit = defineEmits(['update:modelValue']);\n\nconst sortIcon = computed(() => (props.modelValue === 'desc'\n  ? SolarSortFromTopToBottomLineDuotone\n  : SolarSortFromBottomToTopLineDuotone));\n\nconst toggleSort = () => {\n  emit('update:modelValue', props.modelValue === 'desc' ? 'asc' : 'desc');\n};\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/TextEditor.vue",
    "content": "<template>\n  <div\n    v-if=\"editor\"\n    class=\"flex size-full flex-col\"\n  >\n    <div class=\"sticky top-0 z-10 flex flex-wrap items-center gap-1 bg-white p-2 text-xs text-black dark:bg-black dark:text-white\">\n      <button\n        type=\"button\"\n        class=\"p-1\"\n        :class=\"{ 'rounded bg-black text-white dark:bg-white dark:text-black': editor.isActive('bold') }\"\n        @click=\"editor.chain().focus().toggleBold().run()\"\n      >\n        <PhTextB class=\"size-5\" />\n      </button>\n      <button\n        type=\"button\"\n        class=\"p-1\"\n        :class=\"{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('italic') }\"\n        @click=\"editor.chain().focus().toggleItalic().run()\"\n      >\n        <PhTextItalic class=\"size-5\" />\n      </button>\n      <button\n        type=\"button\"\n        class=\"p-1\"\n        :class=\"{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('strike') }\"\n        @click=\"editor.chain().focus().toggleStrike().run()\"\n      >\n        <PhTextStrikethrough class=\"size-5\" />\n      </button>\n      <button\n        type=\"button\"\n        class=\"p-1\"\n        :class=\"{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('underline') }\"\n        @click=\"editor.chain().focus().toggleUnderline().run()\"\n      >\n        <PhTextUnderline class=\"size-5\" />\n      </button>\n      <button\n        type=\"button\"\n        class=\"p-1\"\n        :class=\"{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('heading', { level: 1 }) }\"\n        @click=\"editor.chain().focus().toggleHeading({ level: 1 }).run()\"\n      >\n        <PhTextHOne class=\"size-5\" />\n      </button>\n      <button\n        type=\"button\"\n        class=\"p-1\"\n        :class=\"{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('heading', { level: 2 }) }\"\n        @click=\"editor.chain().focus().toggleHeading({ level: 2 }).run()\"\n      >\n        <PhTextHTwo class=\"size-5\" />\n      </button>\n      <button\n        type=\"button\"\n        class=\"p-1\"\n        :class=\"{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('heading', { level: 3 }) }\"\n        @click=\"editor.chain().focus().toggleHeading({ level: 3 }).run()\"\n      >\n        <PhTextHThree class=\"size-5\" />\n      </button>\n      <button\n        type=\"button\"\n        class=\"p-1\"\n        :class=\"{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('heading', { level: 4 }) }\"\n        @click=\"editor.chain().focus().toggleHeading({ level: 4 }).run()\"\n      >\n        <PhTextHFour class=\"size-5\" />\n      </button>\n      <button\n        type=\"button\"\n        class=\"p-1\"\n        :class=\"{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('bulletList') }\"\n        @click=\"editor.chain().focus().toggleBulletList().run()\"\n      >\n        <PhListBullets class=\"size-5\" />\n      </button>\n      <button\n        type=\"button\"\n        class=\"p-1\"\n        :class=\"{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('orderedList') }\"\n        @click=\"editor.chain().focus().toggleOrderedList().run()\"\n      >\n        <PhListNumbers class=\"size-5\" />\n      </button>\n      <button\n        type=\"button\"\n        class=\"p-1\"\n        :class=\"{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('codeBlock') }\"\n        @click=\"editor.chain().focus().toggleCodeBlock().run()\"\n      >\n        <PhCode class=\"size-5\" />\n      </button>\n      <button\n        type=\"button\"\n        class=\"p-1\"\n        :class=\"{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('blockquote') }\"\n        @click=\"editor.chain().focus().toggleBlockquote().run()\"\n      >\n        <PhQuotes class=\"size-5\" />\n      </button>\n    </div>\n    <editor-content\n      :editor=\"editor\"\n      class=\"w-full flex-1 overflow-auto p-2 text-black dark:text-white\"\n    />\n  </div>\n</template>\n<script setup>\nimport { ref, watch, onMounted, onBeforeUnmount } from 'vue';\nimport { Editor, EditorContent } from '@tiptap/vue-3';\nimport StarterKit from '@tiptap/starter-kit';\nimport Underline from '@tiptap/extension-underline';\nimport Highlight from '@tiptap/extension-highlight';\nimport Typography from '@tiptap/extension-typography';\nimport PhTextB from '~icons/ph/text-b';\nimport PhTextItalic from '~icons/ph/text-italic';\nimport PhTextStrikethrough from '~icons/ph/text-strikethrough';\nimport PhTextUnderline from '~icons/ph/text-underline';\nimport PhTextHFour from '~icons/ph/text-h-four';\nimport PhTextHOne from '~icons/ph/text-h-one';\nimport PhTextHTwo from '~icons/ph/text-h-two';\nimport PhTextHThree from '~icons/ph/text-h-three';\nimport PhQuotes from '~icons/ph/quotes';\nimport PhListNumbers from '~icons/ph/list-numbers';\nimport PhListBullets from '~icons/ph/list-bullets';\nimport PhCode from '~icons/ph/code';\n\nconst props = defineProps({\n  modelValue: {\n    type: String,\n    default: '',\n  },\n});\nconst emit = defineEmits(['update:modelValue']);\nconst editor = ref(null);\n\nwatch(\n  () => props.modelValue,\n  (value) => {\n    if (editor.value) {\n      const isSame = editor.value.getHTML() === value;\n      if (isSame) return;\n\n      editor.value.commands.setContent(value, false);\n    }\n  },\n);\nonMounted(() => {\n  editor.value = new Editor({\n    extensions: [StarterKit, Underline, Highlight, Typography],\n    editorProps: {\n      attributes: {\n        class: 'prose prose-neutral dark:prose-invert prose-sm max-w-none text-sm min-h-full w-full p-2 focus:outline-none',\n      },\n    },\n    content: props.modelValue,\n    onUpdate: () => {\n      emit('update:modelValue', editor.value.getHTML());\n    },\n  });\n});\nonBeforeUnmount(() => {\n  editor.value.destroy();\n});\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/ThemeMode.vue",
    "content": "<template>\n  <div class=\"relative\">\n    <button\n      class=\" text-gray-700  dark:border-neutral-800  dark:text-white\"\n      @click=\"toggleTheme()\"\n    >\n      <component\n        :is=\"isDark ? IconoirSunLight : IconoirHalfMoon\"\n        class=\"size-4 text-soft-900 hover:text-black dark:text-white dark:hover:text-white\"\n      />\n    </button>\n  </div>\n</template>\n<script setup>\nimport { useDark, useToggle } from '@vueuse/core';\nimport IconoirHalfMoon from '~icons/iconoir/half-moon?width=24px&height=24px';\nimport IconoirSunLight from '~icons/iconoir/sun-light?width=24px&height=24px';\n\nconst isDark = useDark();\nconst toggleTheme = useToggle(isDark);\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/ViewMode.vue",
    "content": "<template>\n  <Menu\n    as=\"div\"\n    class=\"relative inline-block text-left\"\n  >\n    <MenuButton\n      class=\"inline-flex size-9 items-center justify-center rounded-md border-1 border-gray-400/30 bg-white text-gray-700 shadow-sm hover:bg-gray-50 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800\"\n    >\n      <component\n        :is=\"icon\"\n        class=\"m-auto size-5 cursor-pointer text-gray-700 dark:text-neutral-400\"\n        aria-hidden=\"true\"\n      />\n    </MenuButton>\n\n    <Transition\n      enter-active-class=\"transition duration-200 ease-out\"\n      enter-from-class=\"translate-y-1 opacity-0\"\n      enter-to-class=\"translate-y-0 opacity-100\"\n      leave-active-class=\"transition duration-150 ease-in\"\n      leave-from-class=\"translate-y-0 opacity-100\"\n      leave-to-class=\"translate-y-1 opacity-0\"\n    >\n      <MenuItems\n        class=\"absolute right-0 mt-2 w-40 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 dark:bg-neutral-900 dark:text-neutral-400\"\n      >\n        <MenuItem v-slot=\"{ active }\">\n          <button\n            :class=\"[\n              active\n                ? 'bg-neutral-50 text-gray-700 dark:bg-neutral-800  dark:text-neutral-400'\n                : 'text-gray-700 dark:text-neutral-400',\n              'group flex w-full items-center rounded-md p-2 text-xs',\n            ]\"\n            @click=\"view = 'masonry'\"\n          >\n            <CircumGrid42\n              :active=\"active\"\n              class=\"mr-2 size-5\"\n              aria-hidden=\"true\"\n            />\n            Gallery\n          </button>\n        </MenuItem>\n        <MenuItem v-slot=\"{ active }\">\n          <button\n            :class=\"[\n              active\n                ? 'bg-neutral-50 text-gray-700 dark:bg-neutral-800 dark:text-neutral-400'\n                : 'text-gray-700 dark:text-neutral-400',\n              'group flex w-full items-center rounded-md p-2 text-xs',\n            ]\"\n            @click=\"view = 'card'\"\n          >\n            <CircumGrid41\n              :active=\"active\"\n              class=\"mr-2 size-5\"\n              aria-hidden=\"true\"\n            />\n            Cards\n          </button>\n        </MenuItem>\n        <MenuItem v-slot=\"{ active }\">\n          <button\n            :class=\"[\n              active\n                ? 'bg-neutral-50 text-gray-700 dark:bg-neutral-800 dark:text-neutral-400'\n                : 'text-gray-700 dark:text-neutral-400',\n              'group flex w-full items-center rounded-md p-2 text-xs',\n            ]\"\n            @click=\"view = 'list'\"\n          >\n            <CircumGrid2H\n              :active=\"active\"\n              class=\"mr-2 size-5\"\n              aria-hidden=\"true\"\n            />\n            List\n          </button>\n        </MenuItem>\n      </MenuItems>\n    </Transition>\n  </Menu>\n</template>\n<script setup>\nimport {\n  Menu, MenuButton, MenuItems, MenuItem,\n} from '@headlessui/vue';\n\nimport { computed } from 'vue';\n\nimport CircumGrid42 from '~icons/circum/grid-4-2';\nimport CircumGrid41 from '~icons/circum/grid-4-1';\nimport CircumGrid2H from '~icons/circum/grid-2-h';\n\nconst props = defineProps({\n  modelValue: {\n    type: String,\n    requred: true,\n    default: 'masonry',\n  },\n});\nconst emit = defineEmits(['update:modelValue']);\nconst view = computed({\n  get: () => props.modelValue,\n  set: (value) => emit('update:modelValue', value),\n});\nconst icon = computed({\n  get: () => {\n    switch (view.value) {\n      case 'card':\n        return CircumGrid41;\n      case 'list':\n        return CircumGrid2H;\n      case 'masonry':\n        return CircumGrid42;\n      default:\n        return CircumGrid42;\n    }\n  },\n});\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/card/BookmarkCard.vue",
    "content": "<template>\n  <Transition\n    appear\n    enter-active-class=\"transition-opacity duration-200 ease-out\"\n    enter-from-class=\"opacity-0\"\n    enter-to-class=\"opacity-100\"\n  >\n    <component\n      :is=\"displayComponent\"\n      :key=\"bookmark.id\"\n      :bookmark=\"bookmark\"\n    >\n      <template #actions>\n        <div class=\"absolute right-2 top-0 transition-opacity duration-300 ease-out group-hover:opacity-100\">\n          <div class=\"flex gap-x-2\">\n            <button\n              v-tooltip.bottom-start=\"{ content: 'Delete'}\"\n              class=\"-translate-y-8 rounded-md bg-red-500 p-1.5 text-white opacity-100 shadow-md transition-transform delay-100 duration-150 ease-out group-hover:translate-y-2 group-hover:opacity-100\"\n              @click=\"$emit('onRemove', bookmark)\"\n            >\n              <CarbonTrashCan class=\"size-4\" />\n            </button>\n            <button\n              v-tooltip.bottom-start=\"{ content: 'Add to Notes'}\"\n              class=\"-translate-y-8 rounded-md p-1.5  text-white opacity-100 shadow-md transition-transform delay-100 duration-500 ease-out group-hover:translate-y-2 group-hover:opacity-100\"\n              :class=\"[\n                bookmark.pinned === 0 ? 'bg-black' : 'bg-purple-500 '\n              ]\"\n              @click=\"$emit('onPin', bookmark)\"\n            >\n              <ClarityClipboardOutlineBadged class=\"size-4\" />\n            </button>\n            <button\n              v-tooltip.bottom-start=\"{ content: 'Update bookmark'}\"\n              class=\"-translate-y-8  rounded-md bg-black p-1.5 text-white opacity-100 shadow-md transition-transform delay-100 duration-1000 ease-out group-hover:translate-y-2 group-hover:opacity-100\"\n              @click=\"$emit('onEdit', bookmark)\"\n            >\n              <CarbonEdit class=\"size-4\" />\n            </button>\n          </div>\n        </div>\n      </template>\n    </component>\n  </Transition>\n</template>\n<script setup>\nimport { computed } from 'vue';\nimport CardView from '@/ext/browser/components/card/type/CardView.vue';\nimport ListView from '@/ext/browser/components/card/type/ListView.vue';\nimport MasonryView from '@/ext/browser/components/card/type/MasonryView.vue';\n\nimport CarbonTrashCan from '~icons/carbon/trash-can';\nimport ClarityClipboardOutlineBadged from '~icons/clarity/clipboard-outline-badged';\nimport CarbonEdit from '~icons/carbon/edit';\n\nconst props = defineProps({\n  bookmark: {\n    type: Object,\n    required: true,\n    default: () => {},\n  },\n  displayType: {\n    type: String,\n    required: true,\n    default: 'masonry',\n  },\n});\ndefineEmits(['onRemove', 'onEdit', 'onPin', 'onScreenshot']);\n\nconst displayComponent = computed({\n  get: () => {\n    switch (props.displayType) {\n      case 'card':\n        return CardView;\n      case 'list':\n        return ListView;\n      case 'masonry':\n        return MasonryView;\n      default:\n        return MasonryView;\n    }\n  },\n});\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/card/DuplicateCard.vue",
    "content": "<template>\n  <div class=\"group relative w-full rounded-md border border-solid bg-white shadow-xs transition-all duration-200 ease-in-out hover:bg-gray-50 hover:shadow-sm dark:border-neutral-900 dark:bg-neutral-950 dark:hover:bg-neutral-900\">\n    <div class=\"flex items-center justify-between w-full p-3 text-gray-900 dark:text-neutral-100\">\n      <div class=\"flex items-center gap-x-3 min-w-0 flex-1\">\n        <BookmarkFavicon\n          :bookmark=\"bookmark\"\n          class=\"size-4 fill-gray-700 dark:fill-gray-100\"\n        />\n        <div class=\"min-w-0\">\n          <div class=\"flex items-center gap-x-2 min-w-0 overflow-hidden\">\n            <a\n              :href=\"bookmark.url\"\n              target=\"_blank\"\n              class=\"focus-visible:ring-2 focus-visible:ring-blue-500 rounded text-sm text-black dark:text-white no-underline hover:no-underline truncate\"\n            >\n              {{ bookmark.title }}\n            </a>\n            <span class=\"text-xs text-gray-400 dark:text-gray-500 flex-shrink-0 truncate\">ID:{{ bookmark.id }}</span>\n          </div>\n          <p class=\"text-xs dark:text-neutral-500\">\n            {{ bookmark.domain }}\n          </p>\n          <div class=\"flex flex-wrap gap-x-1 gap-y-1 mt-1\">\n            <AppBadge\n              v-for=\"(value, key) in bookmark.tags\"\n              :key=\"key\"\n              class=\"text-xs px-1.5 py-0.5 truncate max-w-[60px]\"\n            >\n              {{ value }}\n            </AppBadge>\n          </div>\n        </div>\n      </div>\n      <button\n        v-tooltip.bottom-start=\"{ content: 'Delete' }\"\n        class=\"ml-2 p-1 rounded transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30\"\n        aria-label=\"Delete duplicate\"\n        @click=\"$emit('onDelete', bookmark)\"\n      >\n        <CarbonTrashCan class=\"size-4\" />\n      </button>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';\nimport AppBadge from '@/components/app/AppBadge.vue';\nimport CarbonTrashCan from '~icons/carbon/trash-can';\n\ndefineProps({\n  bookmark: {\n    type: Object,\n    required: true,\n  },\n});\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/card/HealthCheckCard.vue",
    "content": "<template>\n  <div class=\"group relative w-full rounded-md border border-solid bg-white shadow-xs transition-all duration-200 ease-in-out hover:bg-gray-50 hover:shadow-sm dark:border-neutral-900 dark:bg-neutral-950 dark:hover:bg-neutral-900\">\n    <div class=\"flex items-center justify-between w-full p-3 text-gray-900 dark:text-neutral-100\">\n      <div class=\"flex items-center gap-x-3 min-w-0 flex-1\">\n        <AppBadge\n          v-tooltip.bottom-start=\"{ content: STATUS_MESSAGE.get(bookmark.httpStatus) ?? 'Unknown Status' }\"\n          :color=\"bookmark.httpStatus === HTTP_STATUS.UNKNOWN_ERROR ? 'yellow' : 'red'\"\n        >\n          {{ bookmark.httpStatus }}\n        </AppBadge>\n        <BookmarkFavicon\n          :bookmark=\"bookmark\"\n          class=\"size-4 fill-gray-700 dark:fill-gray-100\"\n        />\n        <div class=\"min-w-0\">\n          <a\n            :href=\"bookmark.url\"\n            target=\"_blank\"\n            class=\"focus-visible:ring-2 focus-visible:ring-blue-500 rounded text-sm text-black dark:text-white no-underline hover:no-underline\"\n          >\n            {{ bookmark.title }}\n          </a>\n          <p class=\"text-xs dark:text-neutral-500\">\n            {{ bookmark.domain }}\n          </p>\n        </div>\n      </div>\n      <button\n        v-tooltip.bottom-start=\"{ content: 'Delete'}\"\n        class=\"ml-2 p-1 rounded transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30\"\n        aria-label=\"Delete bookmark\"\n        @click=\"$emit('onDelete', bookmark)\"\n      >\n        <CarbonTrashCan class=\"size-4\" />\n      </button>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';\nimport AppBadge from '@/components/app/AppBadge.vue';\nimport { STATUS_MESSAGE, HTTP_STATUS } from '@/constants/httpStatus';\nimport CarbonTrashCan from '~icons/carbon/trash-can';\n\ndefineProps({\n  bookmark: {\n    type: Object,\n    required: true,\n  },\n});\n\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/card/PinnedCard.vue",
    "content": "<template>\n  <div\n    class=\"group relative min-h-min w-full overflow-hidden rounded-md border border-solid p-3 shadow-xs dark:border-neutral-800 dark:bg-neutral-950\"\n    :class=\"activeClass\"\n  >\n    <div class=\"mb-1 flex items-center gap-x-1.5 text-sm text-black dark:text-white\">\n      <AppBullet\n        v-if=\"bookmark.notes\"\n        v-tooltip.top=\"'Has notes'\"\n        color=\"purple\"\n        :size=\"2\"\n        class=\"flex-shrink-0\"\n      />\n      <span class=\"truncate\">{{ bookmark.title }}</span>\n    </div>\n    <div class=\"flex items-center justify-between text-xs text-black dark:text-white mt-2\">\n      <div class=\"flex items-center gap-x-2 min-w-0\">\n        <BookmarkFavicon\n          :bookmark=\"bookmark\"\n          class=\"size-3\"\n        />\n        <span class=\"truncate\">{{ bookmark.domain }}</span>\n      </div>\n      <div class=\"flex items-center text-xs text-gray-400 flex-shrink-0 ml-2\">\n        <CarbonTime class=\"mr-1\" />\n        <span class=\"mr-1\">Last viewed:</span>\n        <span>{{ new Date(bookmark.updatedAt).toLocaleString() }}</span>\n      </div>\n    </div>\n\n    <div class=\"absolute right-2 top-0 transition-opacity duration-300 ease-out group-hover:opacity-100\">\n      <div class=\"flex gap-x-2\">\n        <button\n          v-tooltip.bottom-start=\"{ content: 'Unpin bookmark'}\"\n          aria-label=\"Unpin bookmark\"\n          class=\"-translate-y-8 rounded-md p-1.5 text-white opacity-100 shadow-md transition-transform delay-100 duration-300 ease-out group-hover:translate-y-2 group-hover:opacity-100\"\n          :class=\"[\n            bookmark.pinned === 0 ? 'bg-black' : 'bg-purple-500 '\n          ]\"\n          @click=\"$emit('pin', bookmark)\"\n        >\n          <CarbonPin class=\"size-4\" />\n        </button>\n        <button\n          v-tooltip.bottom-start=\"{ content: 'Open'}\"\n          aria-label=\"Open bookmark\"\n          class=\"-translate-y-8 rounded-md bg-black p-1.5 text-white opacity-100 shadow-md transition-transform delay-100 duration-500 ease-out group-hover:translate-y-2 group-hover:opacity-100\"\n          @click=\"$emit('open', bookmark)\"\n        >\n          <CarbonNewTab class=\"size-4\" />\n        </button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { computed } from 'vue';\nimport AppBullet from '@/components/app/AppBullet.vue';\nimport BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';\nimport CarbonPin from '~icons/carbon/pin';\nimport CarbonNewTab from '~icons/carbon/new-tab';\nimport CarbonTime from '~icons/carbon/time';\n\nconst props = defineProps({\n  bookmark: {\n    type: Object,\n    required: true,\n  },\n  active: {\n    type: Boolean,\n    default: false,\n  },\n});\n\ndefineEmits(['remove', 'pin', 'edit']);\n\nconst activeClass = computed(() => (props.active ? 'bg-gray-100 dark:bg-neutral-600' : 'bg-white dark:bg-neutral-950'));\n\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/card/type/CardView.vue",
    "content": "<template>\n  <div\n    class=\"group relative w-full max-w-md mx-auto overflow-hidden rounded-xl border border-solid border-gray-200 bg-white shadow-sm p-0 flex flex-col dark:border-neutral-800 dark:bg-neutral-950\"\n  >\n    <div class=\"px-4 pt-4 flex-1 flex flex-col\">\n      <a\n        :href=\"bookmark.url\"\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        class=\"break-words text-sm text-black dark:text-white line-clamp-3\"\n      >\n        {{ bookmark.title }}\n      </a>\n      <div\n        v-if=\"bookmark.tags && bookmark.tags.length\"\n        class=\"flex flex-wrap items-center gap-2 my-2\"\n      >\n        <AppBadge\n          v-for=\"(value, key) in bookmark.tags\"\n          :key=\"key\"\n        >\n          {{ value }}\n        </AppBadge>\n      </div>\n    </div>\n    <!-- preview image -->\n    <a\n      :href=\"bookmark.url\"\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      :aria-label=\"`Open link: ${bookmark.title}`\"\n      class=\"block px-4 py-1\"\n    >\n      <div class=\"w-full aspect-video rounded-md bg-gray-100 dark:bg-neutral-900 flex items-center justify-center overflow-hidden\">\n        <img\n          v-if=\"bookmark.image && !imageError\"\n          :src=\"String(bookmark.image)\"\n          :alt=\"bookmark.title\"\n          class=\"w-full h-full object-cover rounded-md\"\n          @error=\"imageError = true\"\n        >\n        <div\n          v-else\n          class=\"flex items-center justify-center w-full h-full\"\n        >\n          <BookmarkFavicon\n            :bookmark=\"bookmark\"\n            class=\"max-w-8 max-h-8\"\n          />\n        </div>\n      </div>\n    </a>\n    <!-- footer -->\n    <div class=\"flex flex-col gap-2 px-4 py-3 border-t border-gray-200 mt-2 sm:flex-row sm:items-center sm:justify-between dark:border-neutral-800\">\n      <span class=\"flex items-center gap-1 text-xs text-gray-400 dark:text-neutral-500 min-w-0 truncate\">\n        <BookmarkFavicon\n          :bookmark=\"bookmark\"\n          class=\"w-3 h-3 shrink-0\"\n        />\n        <span class=\"truncate\">{{ bookmark.domain }}</span>\n      </span>\n      <div class=\"flex items-center gap-2 sm:order-last\">\n        <slot name=\"actions\" />\n        <span class=\"flex items-center text-xs text-gray-400 dark:text-neutral-500 whitespace-nowrap\">\n          <PhCalendarBlank class=\"mr-1 align-text-bottom shrink-0\" />\n          {{ new Date(bookmark.dateAdded).toISOString().slice(0, 10) }}\n        </span>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref } from 'vue';\nimport AppBadge from '@/components/app/AppBadge.vue';\nimport BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';\nimport PhCalendarBlank from '~icons/ph/calendar-blank';\n\ndefineProps({\n  bookmark: {\n    type: Object,\n    required: true,\n  },\n});\n\nconst imageError = ref(false);\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/card/type/ListView.vue",
    "content": "<template>\n  <div\n    class=\"group relative min-h-min w-full overflow-hidden rounded-md border border-solid border-gray-200 bg-white p-3 shadow-xs dark:border-neutral-900 dark:bg-neutral-950\"\n  >\n    <a\n      :href=\"bookmark.url\"\n      rel=\"noopener noreferrer\"\n      target=\"_blank\"\n      class=\"w-full block\"\n    >\n      <div class=\"mb-3 flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2\">\n        <span class=\"text-sm text-black dark:text-white leading-snug\">{{ bookmark.title }}</span>\n        <span class=\"flex items-center text-xs text-gray-400 dark:text-neutral-500 shrink-0\">\n          <PhCalendarBlank class=\"text-xs mr-1\" />\n          {{ new Date(bookmark.dateAdded).toISOString().slice(0, 10) }}\n        </span>\n      </div>\n      <div class=\"flex flex-col gap-2.5\">\n        <span class=\"text-xs text-gray-700 dark:text-neutral-100 flex items-center gap-1.5\">\n          <BookmarkFavicon\n            :bookmark=\"bookmark\"\n            class=\"w-3 h-3 shrink-0\"\n          />\n          {{ bookmark.domain }}\n        </span>\n        <p\n          v-if=\"bookmark.description\"\n          class=\"break-words text-xs text-gray-700 dark:text-neutral-500 leading-relaxed\"\n        >\n          {{ bookmark.description }}\n        </p>\n        <div\n          v-if=\"bookmark.tags && bookmark.tags.length\"\n          class=\"flex flex-wrap gap-2\"\n        >§\n          <AppBadge\n            v-for=\"(value, key) in bookmark.tags\"\n            :key=\"key\"\n          >\n            {{ value }}\n          </AppBadge>\n        </div>\n      </div>\n    </a>\n    <slot name=\"actions\" />\n  </div>\n</template>\n<script setup>\nimport { computed } from 'vue';\nimport AppBadge from '@/components/app/AppBadge.vue';\nimport BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';\nimport PhCalendarBlank from '~icons/ph/calendar-blank';\n\nconst props = defineProps({\n  bookmark: {\n    type: Object,\n    required: true,\n  },\n});\n\nconst bookmark = computed({\n  get: () => props.bookmark,\n});\n</script>\n"
  },
  {
    "path": "src/ext/browser/components/card/type/MasonryView.vue",
    "content": "<template>\n  <div class=\"group relative\">\n    <!-- Animated border background -->\n    <div\n      :class=\"['animated-gradient absolute -inset-0.5 rounded-lg bg-gradient-to-r opacity-0 transition-all duration-500 group-hover:opacity-60', randomGradient]\"\n    />\n    <!-- Glow effect -->\n    <div\n      :class=\"['glow-effect absolute -inset-2 opacity-0 blur-lg transition-all duration-300 group-hover:opacity-50 dark:group-hover:opacity-100', glowClass]\"\n    />\n    <div\n      class=\"group relative mb-1 min-h-[60px] sm:min-h-[80px] w-full overflow-hidden rounded-md border border-solid border-gray-100 bg-white shadow-sm hover:[box-shadow:0px_0px_0px_1px_rgba(233,_226,_238,_0.253)] dark:border-neutral-900 dark:bg-neutral-950 dark:shadow-sm dark:hover:[box-shadow:0px_0px_0px_1px_rgba(233,_226,_238,_0.253)] sm:mb-2 md:mb-3\"\n    >\n      <a\n        :href=\"bookmark.url\"\n        rel=\"noopener noreferrer\"\n        target=\"_blank\"\n      >\n        <!-- image -->\n        <div\n          class=\"flex w-full items-center justify-center relative\"\n          :class=\"{\n            'min-h-[80px] sm:min-h-[120px]': !bookmark.image || imageError,\n            'min-h-[60px] sm:min-h-[80px]': bookmark.image && !imageError\n          }\"\n          :style=\"(!bookmark.image || imageError) ? placeholder : null\"\n        >\n          <img\n            v-if=\"bookmark.image && !imageError\"\n            :key=\"bookmark.id\"\n            :src=\"String(bookmark.image)\"\n            :alt=\"bookmark.title\"\n            class=\"max-h-full max-w-full object-cover transition-all duration-700 ease-out relative\"\n            :class=\"{ 'opacity-0': imageLoading }\"\n            @loadstart=\"imageLoading = true; imageError = false\"\n            @load=\"imageLoading = false\"\n            @error=\"onImageError\"\n          >\n          <div\n            v-if=\"imageLoading && bookmark.image && !imageError\"\n            class=\"absolute inset-0 flex items-center justify-center z-10\"\n          >\n            <AppSpinner />\n          </div>\n          <div\n            v-if=\"!bookmark.image || imageError\"\n            class=\"relative flex size-full items-center justify-center overflow-hidden min-w-0\"\n          >\n            <div class=\"relative z-0 flex flex-col items-center p-6 w-full min-w-0\">\n\n              <div\n                class=\"group relative flex aspect-square size-20 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/50 dark:border-white/10 bg-white/20 dark:bg-white/5 shadow-lg backdrop-blur-xl mb-4\"\n              >\n                <BookmarkFavicon\n                  :bookmark=\"bookmark\"\n                  class=\"relative z-10 max-h-8 max-w-8 rounded-sm object-contain\"\n                />\n              </div>\n\n              <div class=\"text-center w-full min-w-0\">\n                <p class=\"text-sm font-medium text-gray-700 dark:text-gray-200 truncate px-1\">\n                  {{ bookmark.domain || bookmark.title }}\n                </p>\n                <p class=\"text-xs text-gray-500 dark:text-gray-400\">\n                  No preview available\n                </p>\n              </div>\n\n            </div>\n          </div>\n        </div>\n        <!-- end image -->\n\n        <div class=\"p-2 sm:p-3\">\n          <h1 class=\"break-words text-sm text-black dark:text-white line-clamp-3\">{{ bookmark.title }}</h1>\n          <p class=\"break-words py-2 text-xs text-gray-700 dark:text-neutral-500\">\n            {{ bookmark.description }}\n          </p>\n          <div class=\"flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between\">\n            <span class=\"flex items-center gap-1 text-xs text-gray-400 dark:text-neutral-500 min-w-0 truncate\">\n              <BookmarkFavicon\n                :bookmark=\"bookmark\"\n                class=\"size-4 shrink-0\"\n              />\n              <span class=\"truncate\">{{ bookmark.domain }}</span>\n            </span>\n            <span\n              v-if=\"bookmark.dateAdded\"\n              class=\"flex items-center text-xs text-gray-400 dark:text-neutral-500 whitespace-nowrap\"\n            >\n              <PhCalendarBlank class=\"text-xs mr-1 align-text-bottom text-gray-400 dark:text-neutral-500 shrink-0\" />\n              {{ new Date(bookmark.dateAdded).toISOString().slice(0, 10) }}\n            </span>\n          </div>\n          <div\n            v-if=\"bookmark.tags && bookmark.tags.length\"\n            class=\"mt-5 flex flex-wrap gap-1\"\n          >\n            <AppBadge\n              v-for=\"(value, key) in bookmark.tags\"\n              :key=\"key\"\n            >\n              {{ value }}\n            </AppBadge>\n          </div>\n        </div>\n      </a>\n      <slot name=\"actions\" />\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { computed, onMounted, ref } from 'vue';\nimport AppBadge from '@/components/app/AppBadge.vue';\nimport AppSpinner from '@/components/app/AppSpinner.vue';\nimport BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';\nimport useColorExtraction from '@/composables/useColorExtraction';\nimport PhCalendarBlank from '~icons/ph/calendar-blank';\n\nconst props = defineProps({\n  bookmark: {\n    type: Object,\n    required: true,\n  },\n});\n\nconst imageError = ref(false);\nconst imageLoading = ref(true);\nconst { placeholder, extract } = useColorExtraction();\nconst faviconUrl = `https://icons.duckduckgo.com/ip3/${props.bookmark.domain}.ico`;\nconst cacheKey = `fav_${props.bookmark.domain}`;\nconst onImageError = async () => {\n  imageError.value = true;\n  imageLoading.value = false;\n  extract(faviconUrl, cacheKey);\n};\n\nonMounted(async () => {\n  if (!props.bookmark.image) {\n    imageLoading.value = false;\n    extract(faviconUrl, cacheKey);\n  }\n});\nconst gradientClasses = [\n  'gradient-cyan-blue',\n  'gradient-indigo-violet',\n  'gradient-sky-cyan',\n  'gradient-amber-orange',\n  'gradient-rose-pink',\n  'gradient-emerald-teal',\n  'gradient-purple-fuchsia',\n  'gradient-violet-purple',\n  'gradient-blue-cyan',\n  'gradient-pink-rose',\n];\n\nconst randomGradient = computed(() => gradientClasses[Math.floor(Math.random() * gradientClasses.length)]);\n\nconst glowClass = computed(() => `glow-${randomGradient.value.replace('gradient-', '')}`);\n</script>\n\n<style scoped>\n@keyframes gradient-shift {\n  0%, 100% {\n    background-position: 0% 50%;\n  }\n  50% {\n    background-position: 100% 50%;\n  }\n}\n\n.group:hover .animated-gradient {\n  background-size: 200% 200%;\n  animation: gradient-shift 4s ease-in-out infinite;\n}\n\n/* Gradient classes with full definitions */\n.gradient-cyan-blue {\n  background: linear-gradient(to right, #22d3ee, #93c5fd, #22d3ee);\n}\n\n.gradient-indigo-violet {\n  background: linear-gradient(to right, #818cf8, #c4b5fd, #818cf8);\n}\n\n.gradient-sky-cyan {\n  background: linear-gradient(to right, #0ea5e9, #22d3ee, #0ea5e9);\n}\n\n.gradient-amber-orange {\n  background: linear-gradient(to right, #fbbf24, #fdba74, #fbbf24);\n}\n\n.gradient-rose-pink {\n  background: linear-gradient(to right, #f43f5e, #f9a8d4, #f43f5e);\n}\n\n.gradient-emerald-teal {\n  background: linear-gradient(to right, #10b981, #5eead4, #10b981);\n}\n\n.gradient-purple-fuchsia {\n  background: linear-gradient(to right, #a855f7, #e879f9, #a855f7);\n}\n\n.gradient-violet-purple {\n  background: linear-gradient(to right, #8b5cf6, #a855f7, #8b5cf6);\n}\n\n.gradient-blue-cyan {\n  background: linear-gradient(to right, #3b82f6, #22d3ee, #3b82f6);\n}\n\n.gradient-pink-rose {\n  background: linear-gradient(to right, #ec4899, #f43f5e, #ec4899);\n}\n\n/* Glow effect classes */\n.glow-cyan-blue {\n  background: radial-gradient(circle, rgba(34, 211, 238, 0.5) 0%, rgba(59, 130, 246, 0.5) 40%, rgba(34, 211, 238, 0.5) 70%, transparent 100%);\n}\n\n.glow-indigo-violet {\n  background: radial-gradient(circle, rgba(99, 102, 241, 0.5) 0%, rgba(139, 92, 246, 0.5) 40%, rgba(99, 102, 241, 0.5) 70%, transparent 100%);\n}\n\n.glow-sky-cyan {\n  background: radial-gradient(circle, rgba(14, 165, 233, 0.5) 0%, rgba(34, 211, 238, 0.5) 40%, rgba(14, 165, 233, 0.5) 70%, transparent 100%);\n}\n\n.glow-amber-orange {\n  background: radial-gradient(circle, rgba(251, 191, 36, 0.5) 0%, rgba(251, 146, 60, 0.5) 40%, rgba(251, 191, 36, 0.5) 70%, transparent 100%);\n}\n\n.glow-rose-pink {\n  background: radial-gradient(circle, rgba(244, 63, 94, 0.5) 0%, rgba(236, 72, 153, 0.5) 40%, rgba(244, 63, 94, 0.5) 70%, transparent 100%);\n}\n\n.glow-emerald-teal {\n  background: radial-gradient(circle, rgba(16, 185, 129, 0.5) 0%, rgba(20, 184, 166, 0.5) 40%, rgba(16, 185, 129, 0.5) 70%, transparent 100%);\n}\n\n.glow-purple-fuchsia {\n  background: radial-gradient(circle, rgba(147, 51, 234, 0.5) 0%, rgba(217, 70, 239, 0.5) 40%, rgba(147, 51, 234, 0.5) 70%, transparent 100%);\n}\n\n.glow-violet-purple {\n  background: radial-gradient(circle, rgba(139, 92, 246, 0.5) 0%, rgba(147, 51, 234, 0.5) 40%, rgba(139, 92, 246, 0.5) 70%, transparent 100%);\n}\n\n.glow-blue-cyan {\n  background: radial-gradient(circle, rgba(59, 130, 246, 0.5) 0%, rgba(34, 211, 238, 0.5) 40%, rgba(59, 130, 246, 0.5) 70%, transparent 100%);\n}\n\n.glow-pink-rose {\n  background: radial-gradient(circle, rgba(236, 72, 153, 0.5) 0%, rgba(244, 63, 94, 0.5) 40%, rgba(236, 72, 153, 0.5) 70%, transparent 100%);\n}\n</style>\n"
  },
  {
    "path": "src/ext/browser/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <link rel=\"icon\" href=\"/favicon.ico\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>FavBox</title>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"app.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "src/ext/browser/layouts/AppLayout.vue",
    "content": "<template>\n  <div class=\"flex h-screen w-full scroll-smooth font-sans\">\n    <ASide :items=\"menu\" />\n    <main class=\"size-full overflow-hidden\">\n      <Suspense>\n        <RouterView v-slot=\"{ Component, route }\">\n          <KeepAlive include=\"HealthCheckView\">\n            <component\n              :is=\"Component\"\n              :key=\"route.name\"\n            />\n          </KeepAlive>\n        </RouterView>\n      </Suspense>\n    </main>\n    <AppNotifications />\n  </div>\n</template>\n\n<script setup>\nimport { onErrorCaptured } from 'vue';\nimport { notify } from 'notiwind';\nimport AppNotifications from '@/components/app/AppNotifications.vue';\nimport ASide from '@/ext/browser/components/ASide.vue';\nimport ClarityBookmarkLine from '~icons/clarity/bookmark-line';\nimport ClarityCopyLine from '~icons/clarity/copy-line';\nimport PhLinkBreak from '~icons/ph/link-break';\nimport ClarityClipboardOutlineBadged from '~icons/clarity/clipboard-outline-badged';\n\nconst menu = [\n  { name: 'BookmarksView', label: 'Bookmarks', icon: ClarityBookmarkLine, tooltip: 'View all bookmarks' },\n  { name: 'NotesView', label: 'Notes', icon: ClarityClipboardOutlineBadged, tooltip: 'Bookmarks with notes' },\n  { name: 'HealthCheckView', label: 'Health Check', icon: PhLinkBreak, tooltip: 'Check broken links' },\n  { name: 'DuplicatesView', label: 'Duplicates', icon: ClarityCopyLine, tooltip: 'Find duplicate bookmarks' },\n];\n\nonErrorCaptured((e) => {\n  notify({ group: 'error', text: e.message }, 8500);\n});\n</script>\n\n<style>\nhtml, body {\n  overflow: hidden;\n}\n</style>\n"
  },
  {
    "path": "src/ext/browser/router.js",
    "content": "import { createWebHashHistory, createRouter } from 'vue-router';\n\nconst routes = [\n  {\n    path: '/bookmarks',\n    alias: '/ext/browser/index.html',\n    name: 'BookmarksView',\n    component: () => import('./views/BookmarksView.vue'),\n    meta: {\n      page: 1,\n    },\n  },\n  {\n    path: '/bookmarks/:id',\n    name: 'BookmarkDetailView',\n    component: () => import('./views/BookmarksView.vue'),\n    meta: {\n      page: 1,\n    },\n  },\n  {\n    path: '/notes',\n    name: 'NotesView',\n    component: () => import('./views/NotesView.vue'),\n    meta: { page: 2 },\n  },\n  {\n    path: '/health-check',\n    name: 'HealthCheckView',\n    component: () => import('./views/HealthCheckView.vue'),\n    meta: { page: 3 },\n  },\n  {\n    path: '/duplicates',\n    name: 'DuplicatesView',\n    component: () => import('./views/DuplicatesView.vue'),\n    meta: { page: 4 },\n  },\n  {\n    path: '/',\n    redirect: '/bookmarks',\n  },\n  {\n    path: '/:catchAll(.*)',\n    redirect: '/bookmarks',\n  },\n];\n\nconst router = createRouter({\n  history: createWebHashHistory(),\n  routes,\n});\n\nexport default router;\n"
  },
  {
    "path": "src/ext/browser/views/BookmarksView.vue",
    "content": "<template>\n  <div class=\"flex w-full overflow-y-hidden dark:bg-black\">\n    <!-- Sidebar with Tailwind responsive classes -->\n    <div class=\"hidden md:block w-68 max-w-68 flex-shrink-0 transition-all duration-300 ease-in-out\">\n      <div class=\"flex h-screen w-full max-w-64 flex-col border-r border-soft-400 bg-white p-2 dark:border-neutral-800 dark:bg-black transition-all duration-300 ease-in-out\">\n        <TabGroup>\n          <TabList class=\"mb-2 flex gap-1 rounded-md bg-gray-100 p-1 dark:bg-neutral-900\">\n            <Tab\n              v-slot=\"{ selected }\"\n              class=\"w-full focus:outline-none\"\n            >\n              <div\n                :class=\"selected ? 'bg-white shadow-sm dark:bg-neutral-800' : 'hover:bg-gray-200 dark:hover:bg-neutral-700'\"\n                class=\"rounded-md px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors dark:text-neutral-300\"\n              >\n                Search\n              </div>\n            </Tab>\n            <Tab\n              v-slot=\"{ selected }\"\n              class=\"w-full focus:outline-none\"\n            >\n              <div\n                :class=\"selected ? 'bg-white shadow-sm dark:bg-neutral-800' : 'hover:bg-gray-200 dark:hover:bg-neutral-700'\"\n                class=\"rounded-md px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors dark:text-neutral-300\"\n              >\n                Folders\n              </div>\n            </Tab>\n          </TabList>\n\n          <TabPanels class=\"flex-1 overflow-hidden\">\n            <TabPanel class=\"flex h-full flex-col\">\n              <AttributeList\n                v-model=\"bookmarksQuery\"\n                v-model:sort=\"attributesSort\"\n                v-model:includes=\"attributesIncludes\"\n                v-model:term=\"attributesTerm\"\n                :items=\"attributesList\"\n                @paginate=\"skip => loadAttributes({ skip, append: true })\"\n              />\n            </TabPanel>\n\n            <TabPanel class=\"h-full overflow-hidden\">\n              <FolderTree\n                v-model=\"bookmarksQuery\"\n                :folders=\"folderTree\"\n              />\n            </TabPanel>\n          </TabPanels>\n        </TabGroup>\n      </div>\n    </div>\n\n    <AppInfiniteScroll\n      ref=\"scroll\"\n      class=\"flex h-screen w-full flex-col overflow-y-auto\"\n      :limit=\"PAGINATION_LIMIT\"\n      @scroll:end=\"loadMore\"\n    >\n      <div class=\"sticky top-0 z-10 flex w-full flex-row flex-wrap items-center gap-2 bg-white/70 pb-3 pt-2 px-2 backdrop-blur-sm sm:gap-x-3 dark:bg-black/70\">\n        <SearchTerm\n          ref=\"search\"\n          v-model=\"bookmarksQuery\"\n          :placeholder=\"bookmarksTotalPlaceholder\"\n          class=\"flex-1 min-w-0\"\n        />\n        <div class=\"flex shrink-0 gap-x-2 sm:gap-x-3\">\n          <ViewMode\n            v-model=\"viewMode\"\n            v-tooltip.bottom=\"{ content: 'Display mode' }\"\n          />\n          <SortDirection\n            v-model=\"bookmarksSort\"\n            v-tooltip.bottom=\"{ content: 'Sort direction' }\"\n          />\n          <DatePicker v-model=\"selectedDate\" />\n        </div>\n      </div>\n      <div\n        v-if=\"loading\"\n        class=\"flex flex-1 flex-col items-center justify-center p-5\"\n      >\n        <AppSpinner class=\"size-12\" />\n      </div>\n      <div\n        v-else-if=\"bookmarksIsEmpty && !loading\"\n        class=\"flex flex-1 flex-col items-center justify-center p-5\"\n      >\n        <span class=\"px-4 text-center text-lg font-thin text-black sm:text-2xl dark:text-white\">\n          🔍 No bookmarks match your search. Try changing the filters or keywords.\n        </span>\n      </div>\n      <BookmarkLayout\n        v-if=\"!loading\"\n        class=\"p-2\"\n        :display-type=\"viewMode\"\n      >\n        <BookmarkCard\n          v-for=\"bookmark in bookmarksList\"\n          :key=\"bookmark.id\"\n          :display-type=\"viewMode\"\n          :bookmark=\"bookmark\"\n          @on-remove=\"handleRemove\"\n          @on-edit=\"handleEdit\"\n          @on-pin=\"handlePin\"\n        />\n      </BookmarkLayout>\n    </AppInfiniteScroll>\n\n    <AppDrawer ref=\"drawer\">\n      <template #title>\n        Edit Bookmark\n      </template>\n      <template #content>\n        <BookmarkForm\n          :bookmark=\"bookmarksEditState.bookmark\"\n          :folders=\"bookmarksEditState.folders\"\n          :tags=\"bookmarksEditState.tags\"\n          class=\"w-full\"\n          @on-submit=\"handleSubmit\"\n        />\n      </template>\n    </AppDrawer>\n    <BookmarksSync @on-sync=\"sync\" />\n    <AppConfirmation\n      key=\"delete\"\n      ref=\"deleteConfirmation\"\n    >\n      <template #title>\n        Delete bookmark\n      </template>\n      <template #description>\n        Are you sure you want to delete this bookmark? This action cannot be undone. Removing the bookmark from FavBox\n        will also delete it from your browser.\n      </template>\n      <template #cancel>\n        Cancel\n      </template>\n      <template #confirm>\n        Delete\n      </template>\n    </AppConfirmation>\n    <AppConfirmation\n      key=\"screenshot\"\n      ref=\"screenshotRef\"\n    >\n      <template #title>\n        Take a screenshot\n      </template>\n      <template #description>\n        The browser extension will open a new tab, wait for the page to load, then capture a screenshot and save the\n        current preview.\n      </template>\n      <template #cancel>\n        Cancel\n      </template>\n      <template #confirm>\n        Ok\n      </template>\n    </AppConfirmation>\n  </div>\n</template>\n\n<script setup>\nimport {\n  reactive, ref, onMounted, computed, useTemplateRef, watch,\n} from 'vue';\nimport { useRoute, useRouter } from 'vue-router';\nimport { notify } from 'notiwind';\nimport { useStorage, useDebounceFn } from '@vueuse/core';\nimport { PAGINATION_LIMIT, NOTIFICATION_DURATION } from '@/constants/app';\nimport { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue';\nimport AppDrawer from '@/components/app/AppDrawer.vue';\nimport AppSpinner from '@/components/app/AppSpinner.vue';\nimport AttributeList from '@/ext/browser/components/AttributeList.vue';\nimport FolderTree from '@/ext/browser/components/FolderTree.vue';\nimport BookmarkStorage from '@/storage/bookmark';\nimport AttributeStorage from '@/storage/attribute';\n\nimport { getFolderTree } from '@/services/browserBookmarks';\nimport SearchTerm from '@/ext/browser/components/SearchTerm.vue';\nimport ViewMode from '@/ext/browser/components/ViewMode.vue';\nimport BookmarkLayout from '@/ext/browser/components/BookmarkLayout.vue';\nimport AppInfiniteScroll from '@/components/app/AppInfiniteScroll.vue';\nimport BookmarksSync from '@/ext/browser/components/BookmarksSync.vue';\nimport BookmarkCard from '@/ext/browser/components/card/BookmarkCard.vue';\nimport AppConfirmation from '@/components/app/AppConfirmation.vue';\nimport BookmarkForm from '@/ext/browser/components/BookmarkForm.vue';\nimport SortDirection from '@/ext/browser/components/SortDirection.vue';\nimport DatePicker from '@/ext/browser/components/DatePicker.vue';\n\nconst bookmarkStorage = new BookmarkStorage();\nconst attributeStorage = new AttributeStorage();\n\nconst route = useRoute();\nconst router = useRouter();\n\nconst drawerRef = useTemplateRef('drawer');\nconst scrollRef = useTemplateRef('scroll');\nconst deleteConfirmationRef = useTemplateRef('deleteConfirmation');\nconst screenshotRef = useTemplateRef('screenshotRef');\nconst searchRef = useTemplateRef('search');\n\nconst viewMode = useStorage('viewMode', 'masonry');\nconst loading = ref(false);\n\n// Reactive state for bookmarks\nconst bookmarksList = ref([]);\nconst bookmarksTotal = ref(0);\nconst bookmarksQuery = ref(route.params.id ? [{ key: 'id', value: route.params.id }] : []);\nconst bookmarksSort = ref('desc');\nconst selectedDate = ref(null);\n\n// Reactive state for attributes\nconst attributesSort = ref('count:desc');\nconst attributesTerm = ref('');\nconst attributesIncludes = reactive({ domain: true, tag: true, keyword: true });\nconst attributesList = ref([]);\nconst folderTree = ref([]);\n\nconst bookmarksEditState = reactive({\n  bookmark: null,\n  folders: [],\n  tags: [],\n});\n\nconst bookmarksIsEmpty = computed(() => bookmarksList.value.length === 0 && !loading.value);\nconst bookmarksTotalPlaceholder = computed(() => (bookmarksQuery.value.length ? '' : `🚀 Total: ${bookmarksTotal.value}. Search: tag:important domain:example.com`));\n\nconst load = async () => {\n  try {\n    loading.value = true;\n    bookmarksList.value = await bookmarkStorage.search(bookmarksQuery.value, 0, PAGINATION_LIMIT, bookmarksSort.value);\n  } catch (e) {\n    console.error(e);\n    notify({ group: 'error', text: 'Error loading bookmarks.' }, NOTIFICATION_DURATION);\n  } finally {\n    loading.value = false;\n  }\n};\n\nconst loadMore = async (offset) => {\n  try {\n    const more = await bookmarkStorage.search(bookmarksQuery.value, offset, PAGINATION_LIMIT, bookmarksSort.value);\n    bookmarksList.value.push(...more);\n  } catch (e) {\n    console.error(e);\n    notify({ group: 'error', text: 'Error loading bookmarks.' }, NOTIFICATION_DURATION);\n  }\n};\n\nconst sync = async () => {\n  try {\n    bookmarksList.value = [];\n    loading.value = true;\n    bookmarksTotal.value = await bookmarkStorage.total();\n    bookmarksList.value = await bookmarkStorage.search(bookmarksQuery.value, 0, PAGINATION_LIMIT, bookmarksSort.value);\n    attributesList.value = await attributeStorage.search(\n      attributesIncludes,\n      ...attributesSort.value.split(':'),\n      attributesTerm.value,\n      0,\n      PAGINATION_LIMIT,\n    );\n  } catch (error) {\n    console.error('Error refreshing bookmarks:', error);\n    notify({ group: 'error', text: 'Failed to refresh bookmarks.' }, NOTIFICATION_DURATION);\n  } finally {\n    loading.value = false;\n  }\n};\n\nconst loadAttributes = useDebounceFn(async ({ skip = 0, limit = PAGINATION_LIMIT, append = false, includes = attributesIncludes, sort = attributesSort.value, term = attributesTerm.value } = {}) => {\n  try {\n    const [sortColumn, sortDirection] = sort.split(':');\n    const newAttributes = await attributeStorage.search(\n      includes,\n      sortColumn,\n      sortDirection,\n      term,\n      skip,\n      limit,\n    );\n    if (append) {\n      attributesList.value.push(...newAttributes);\n    } else {\n      attributesList.value = newAttributes;\n    }\n  } catch (e) {\n    console.error('Error loading attributes:', e);\n    notify({ group: 'error', text: 'Error loading attributes.' }, NOTIFICATION_DURATION);\n  }\n}, 100);\n\nconst handleRemove = async (bookmark) => {\n  if (await deleteConfirmationRef.value.request() === false) {\n    return;\n  }\n  try {\n    const id = bookmark.id.toString();\n    await browser.bookmarks.remove(id);\n    bookmarksList.value = bookmarksList.value.filter((item) => item.id.toString() !== id);\n    notify({ group: 'default', text: 'Bookmark successfully removed!' }, NOTIFICATION_DURATION);\n    console.log(`Bookmark ${id} successfully removed`);\n    const [sortColumn, sortDirection] = attributesSort.value.split(':');\n    attributesList.value = await attributeStorage.search(attributesIncludes, sortColumn, sortDirection, attributesTerm.value, 0, PAGINATION_LIMIT);\n  } catch (error) {\n    console.error('Error removing bookmark:', error);\n    notify({ group: 'error', text: 'Failed to remove bookmark. Please try again.' }, NOTIFICATION_DURATION);\n  }\n\n  // Silently load more bookmarks if needed, without showing spinner\n  try {\n    if (bookmarksList.value.length < PAGINATION_LIMIT) {\n      const more = await bookmarkStorage.search(bookmarksQuery.value, bookmarksList.value.length, 1, bookmarksSort.value);\n      if (more.length) bookmarksList.value.push(...more);\n    }\n  } catch (e) {\n    console.error('Error loading additional bookmarks after removal:', e);\n  }\n};\n\nconst handleEdit = async (bookmark) => {\n  try {\n    bookmarksEditState.bookmark = JSON.parse(JSON.stringify(bookmark));\n    drawerRef.value.open();\n    const [tags, folders] = await Promise.all([bookmarkStorage.getTags(), getFolderTree()]);\n    bookmarksEditState.tags = tags;\n    bookmarksEditState.folders = folders;\n  } catch (error) {\n    console.error('Failed to load data:', error);\n    notify({ group: 'error', text: 'Error loading data.' }, NOTIFICATION_DURATION);\n  }\n};\n\nconst handlePin = (bookmark) => {\n  try {\n    const status = bookmark.pinned ? 0 : 1;\n    bookmark.pinned = status;\n    bookmarkStorage.updatePinStatusById(bookmark.id, status);\n    const message = status ? 'Added to notes!' : 'Removed from notes!';\n    notify({ group: 'default', text: message }, NOTIFICATION_DURATION);\n  } catch (e) {\n    console.error(e);\n    notify({ group: 'error', text: 'Failed to update pin status.' }, NOTIFICATION_DURATION);\n  }\n};\n\nconst handleSubmit = async (data) => {\n  try {\n    const id = String(data.id);\n    await browser.bookmarks.update(id, { title: data.browserTitle });\n    await browser.bookmarks.move(id, { parentId: String(data.folderId) });\n    const bookmark = bookmarksList.value.find((item) => item.id === id);\n    if (bookmark) {\n      bookmark.title = data.title;\n      bookmark.tags = data.tags;\n      bookmark.folderId = data.folderId;\n      bookmark.folderName = data.folderName;\n    }\n    notify({ group: 'default', text: 'Bookmark successfully saved!' }, NOTIFICATION_DURATION);\n  } catch (error) {\n    console.error('Failed to save bookmark:', error);\n    notify({ group: 'error', text: 'Bookmark not saved.' }, NOTIFICATION_DURATION);\n  } finally {\n    drawerRef.value.close();\n  }\n};\n\nbrowser.runtime.onMessage.addListener(async (message) => {\n  if (message.action === 'refresh') {\n    console.log('Refreshing bookmarks view...');\n    const [attrs, folders, total, bookmarks] = await Promise.all([\n      attributeStorage.search(attributesIncludes, ...attributesSort.value.split(':'), attributesTerm.value, 0, PAGINATION_LIMIT),\n      getFolderTree(),\n      bookmarkStorage.total(),\n      bookmarkStorage.search(bookmarksQuery.value, 0, PAGINATION_LIMIT, bookmarksSort.value),\n    ]);\n    attributesList.value = attrs;\n    folderTree.value = folders;\n    bookmarksTotal.value = total;\n    bookmarksList.value = bookmarks;\n  }\n});\n\nwatch(\n  [bookmarksQuery, bookmarksSort],\n  async ([query]) => {\n    if (!query.some((f) => f.key === 'dateAdded')) {\n      selectedDate.value = null;\n    }\n    await load();\n    scrollRef.value?.scrollUp();\n    if (bookmarksQuery.value.length === 0 && route.params.id) {\n      router.replace({ path: '/bookmarks' });\n    }\n  },\n  { immediate: false },\n);\n\nwatch(selectedDate, (date) => {\n  if (date) {\n    const formatted = date.map((d) => d.toISOString().slice(0, 10));\n    bookmarksQuery.value = [\n      ...bookmarksQuery.value.filter((f) => f.key !== 'dateAdded'),\n      { key: 'dateAdded', value: formatted.join('~') },\n    ];\n  }\n});\n\nwatch(\n  [attributesSort, attributesTerm, attributesIncludes],\n  () => {\n    loadAttributes({ skip: 0, append: false });\n  },\n  { immediate: false },\n);\n\nonMounted(async () => {\n  try {\n    loading.value = true;\n    const [sortColumn, sortDirection] = attributesSort.value.split(':');\n    const [result, totalResult, folders] = await Promise.all([\n      attributeStorage.search(attributesIncludes, sortColumn, sortDirection, attributesTerm.value, 0, PAGINATION_LIMIT),\n      bookmarkStorage.total(),\n      getFolderTree(),\n    ]);\n    attributesList.value = result;\n    bookmarksTotal.value = totalResult;\n    folderTree.value = folders;\n    await load();\n    searchRef.value.focus();\n  } catch (error) {\n    console.error('Error during component mount:', error);\n    notify({ group: 'error', text: 'Error initializing bookmarks view.' }, NOTIFICATION_DURATION);\n  } finally {\n    loading.value = false;\n  }\n});\n</script>\n"
  },
  {
    "path": "src/ext/browser/views/DuplicatesView.vue",
    "content": "<template>\n  <div class=\"h-screen w-full flex flex-col\">\n    <AppInfiniteScroll\n      class=\"flex flex-col flex-1 overflow-y-auto bg-white dark:bg-black\"\n      :limit=\"PAGINATION_LIMIT\"\n      @scroll:end=\"loadMore\"\n    >\n      <div\n        class=\"sticky top-0 z-10 flex w-full flex-col  bg-white/70 p-4 backdrop-blur-sm dark:bg-black/50\"\n      >\n        <div class=\"flex w-full items-center justify-between\">\n          <span class=\"text-xl font-extralight text-black dark:text-white\">\n            Total duplicate groups: <NumberFlow :value=\"total\" />\n          </span>\n        </div>\n      </div>\n      <div\n        v-if=\"loading || groups.length === 0\"\n        class=\"flex flex-1 flex-col items-center justify-center p-5\"\n      >\n        <AppSpinner v-if=\"loading\" />\n        <div\n          v-else\n          class=\"text-2xl text-black dark:text-white\"\n        >\n          🚀 No duplicate bookmarks found.\n        </div>\n      </div>\n      <TransitionGroup\n        v-show=\"groups.length > 0\"\n        enter-active-class=\"transition-opacity duration-200 ease-out\"\n        enter-from-class=\"opacity-0\"\n        enter-to-class=\"opacity-100\"\n        leave-active-class=\"transition-opacity duration-200 ease-in\"\n        leave-from-class=\"opacity-100\"\n        leave-to-class=\"opacity-0\"\n        move-class=\"transition-transform duration-200 ease-out\"\n        tag=\"div\"\n        class=\"flex flex-col gap-y-4 p-4\"\n      >\n        <div\n          v-for=\"group in groups\"\n          :key=\"group.url\"\n          class=\"rounded-md border border-solid bg-white shadow-xs dark:border-neutral-900 dark:bg-neutral-950 mb-3\"\n        >\n          <div class=\"flex items-center justify-between w-full p-3 pb-0\">\n            <div class=\"flex items-center gap-x-2\">\n              <AppBadge color=\"gray\">\n                {{ group.count }}\n              </AppBadge>\n              <span class=\"text-xs text-gray-900 dark:text-white truncate w-full\">\n                <a\n                  :href=\"group.url\"\n                  class=\"block max-w-xs md:max-w-md lg:max-w-2xl truncate hover:underline focus:underline\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  :title=\"group.url\"\n                >\n                  {{ group.url }}\n                </a>\n              </span>\n            </div>\n          </div>\n          <div class=\"flex flex-col gap-y-3 p-3 pt-2\">\n            <DuplicateCard\n              v-for=\"bookmark in group.bookmarks\"\n              :key=\"bookmark.id\"\n              :bookmark=\"bookmark\"\n              @onDelete=\"onDelete\"\n            />\n          </div>\n        </div>\n      </TransitionGroup>\n    </AppInfiniteScroll>\n    <AppConfirmation ref=\"confirmation\">\n      <template #title>\n        Delete bookmark\n      </template>\n      <template #description>\n        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.\n      </template>\n      <template #cancel>\n        Cancel\n      </template>\n      <template #confirm>\n        Delete\n      </template>\n    </AppConfirmation>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, useTemplateRef } from 'vue';\nimport { notify } from 'notiwind';\nimport { PAGINATION_LIMIT, NOTIFICATION_DURATION } from '@/constants/app';\nimport BookmarkStorage from '@/storage/bookmark';\nimport AppInfiniteScroll from '@/components/app/AppInfiniteScroll.vue';\nimport AppSpinner from '@/components/app/AppSpinner.vue';\nimport AppBadge from '@/components/app/AppBadge.vue';\nimport DuplicateCard from '@/ext/browser/components/card/DuplicateCard.vue';\nimport AppConfirmation from '@/components/app/AppConfirmation.vue';\nimport NumberFlow from '@number-flow/vue';\n\nconst bookmarkStorage = new BookmarkStorage();\n\nconst loading = ref(true);\nconst groups = ref([]);\nconst total = ref(0);\nconst confirmationRef = useTemplateRef('confirmation');\nconst deletedGroupsCount = ref(0);\n\nconst load = async () => {\n  try {\n    loading.value = true;\n    const result = await bookmarkStorage.getDuplicatesGrouped(0, PAGINATION_LIMIT);\n    groups.value = result.groups;\n    total.value = result.total;\n  } catch (error) {\n    console.error('Error loading duplicates:', error);\n    notify({ group: 'error', text: 'Error loading duplicates' }, NOTIFICATION_DURATION);\n  } finally {\n    loading.value = false;\n  }\n};\n\nconst loadMore = async (offset) => {\n  try {\n    const result = await bookmarkStorage.getDuplicatesGrouped(offset, PAGINATION_LIMIT);\n    groups.value.push(...result.groups);\n  } catch (error) {\n    console.error('Error loading more duplicates:', error);\n  }\n};\n\nconst findBookmarkInGroups = (bookmarkId) => {\n  for (let i = 0; i < groups.value.length; i++) {\n    const group = groups.value[i];\n    const bookmarkIndex = group.bookmarks.findIndex((b) => b.id === bookmarkId);\n    if (bookmarkIndex !== -1) {\n      return { groupIndex: i, bookmarkIndex };\n    }\n  }\n  return null;\n};\n\nconst removeBookmarkFromGroup = (groupIndex, bookmarkIndex) => {\n  const group = groups.value[groupIndex];\n  group.bookmarks.splice(bookmarkIndex, 1);\n\n  const isGroupEmpty = group.bookmarks.length <= 1;\n  if (isGroupEmpty) {\n    groups.value.splice(groupIndex, 1);\n    total.value -= 1;\n    deletedGroupsCount.value += 1;\n  }\n  return isGroupEmpty;\n};\n\nconst loadOneMoreGroup = async () => {\n  const offset = groups.value.length + deletedGroupsCount.value;\n  if (offset >= total.value) return;\n\n  const result = await bookmarkStorage.getDuplicatesGrouped(offset, 1);\n  if (result.groups.length > 0) {\n    groups.value.push(result.groups[0]);\n  }\n};\n\nconst onDelete = async (bookmark) => {\n  if (await confirmationRef.value.request() === false) return;\n\n  try {\n    await browser.bookmarks.remove(String(bookmark.id));\n\n    const location = findBookmarkInGroups(bookmark.id);\n    if (!location) return;\n\n    const groupRemoved = removeBookmarkFromGroup(location.groupIndex, location.bookmarkIndex);\n    if (groupRemoved) {\n      await loadOneMoreGroup();\n    }\n\n    notify({ group: 'default', text: 'Bookmark deleted.' }, NOTIFICATION_DURATION);\n  } catch (error) {\n    console.error('Error deleting bookmark:', error);\n    notify({ group: 'error', text: 'Error deleting bookmark' }, NOTIFICATION_DURATION);\n  }\n};\n\nonMounted(load);\n</script>\n"
  },
  {
    "path": "src/ext/browser/views/HealthCheckView.vue",
    "content": "<template>\n  <AppInfiniteScroll\n    class=\"flex h-screen w-full flex-col overflow-y-auto bg-white dark:bg-black\"\n    :limit=\"PAGINATION_LIMIT\"\n    @scroll:end=\"loadMore\"\n  >\n    <div\n      class=\"sticky top-0 z-10 flex w-full flex-col border-solid bg-white/70 p-4 backdrop-blur-sm dark:bg-black/50\"\n    >\n      <div class=\"flex w-full items-center justify-between\">\n        <span\n          class=\"text-xl font-extralight text-black dark:text-white\"\n        >\n          Total: <NumberFlow :value=\"total\" />\n        </span>\n        <div class=\"flex gap-x-3\">\n          <AppButton\n            v-if=\"scanning\"\n            variant=\"gray\"\n            @click=\"stop\"\n          >\n            Stop\n          </AppButton>\n          <AppButton\n            v-else\n            @click=\"scan\"\n          >\n            Scan bookmarks\n          </AppButton>\n        </div>\n      </div>\n      <AppProgress\n        v-if=\"scanning\"\n        :progress=\"progress\"\n        class=\"mt-3 w-full\"\n      />\n    </div>\n    <div\n      v-if=\"loading || bookmarks.length === 0\"\n      class=\"flex flex-1 flex-col items-center justify-center p-5\"\n    >\n      <AppSpinner v-if=\"loading\" />\n      <div\n        v-else\n        class=\"text-2xl font-thin text-black dark:text-white\"\n      >\n        ✅ Looks like there are no broken bookmarks in your browser.\n      </div>\n    </div>\n    <TransitionGroup\n      v-show=\"bookmarks.length > 0\"\n      enter-active-class=\"transition-opacity duration-200 ease-out\"\n      enter-from-class=\"opacity-0\"\n      enter-to-class=\"opacity-100\"\n      leave-active-class=\"transition-opacity duration-200 ease-in\"\n      leave-from-class=\"opacity-100\"\n      leave-to-class=\"opacity-0\"\n      move-class=\"transition-transform duration-200 ease-out\"\n      tag=\"div\"\n      class=\"flex flex-col gap-y-3 p-4\"\n    >\n      <HealthCheckCard\n        v-for=\"bookmark in bookmarks\"\n        :key=\"bookmark.id\"\n        :bookmark=\"bookmark\"\n        @on-delete=\"onDelete\"\n      />\n    </TransitionGroup>\n    <AppConfirmation ref=\"confirmation\">\n      <template #title>\n        Delete bookmark\n      </template>\n      <template #description>\n        Are you sure you want to delete this bookmark? This action cannot be undone. Removing the bookmark from FavBox\n        will also delete it from your browser.\n      </template>\n      <template #cancel>\n        Cancel\n      </template>\n      <template #confirm>\n        Delete\n      </template>\n    </AppConfirmation>\n  </AppInfiniteScroll>\n</template>\n\n<script setup>\nimport { ref, onMounted, useTemplateRef, onActivated } from 'vue';\nimport NumberFlow from '@number-flow/vue';\nimport { notify } from 'notiwind';\nimport BookmarkStorage from '@/storage/bookmark';\nimport AppConfirmation from '@/components/app/AppConfirmation.vue';\nimport AppInfiniteScroll from '@/components/app/AppInfiniteScroll.vue';\nimport HealthCheckCard from '@/ext/browser/components/card/HealthCheckCard.vue';\nimport { HTTP_STATUS } from '@/constants/httpStatus';\nimport { PAGINATION_LIMIT, NOTIFICATION_DURATION } from '@/constants/app';\nimport { fetchUrl, fetchHead } from '@/services/httpClient';\nimport AppButton from '@/components/app/AppButton.vue';\nimport AppProgress from '@/components/app/AppProgress.vue';\nimport AppSpinner from '@/components/app/AppSpinner.vue';\n\nconst bookmarkStorage = new BookmarkStorage();\nconst bookmarks = ref([]);\nconst confirmationRef = useTemplateRef('confirmation');\nconst total = ref(0);\nconst loading = ref(true);\nconst scanning = ref(false);\nconst progress = ref(0);\n\nconst httpStatuses = [\n  HTTP_STATUS.NOT_FOUND,\n  HTTP_STATUS.SERVICE_UNAVAILABLE,\n  HTTP_STATUS.INTERNAL_SERVER_ERROR,\n  HTTP_STATUS.GATEWAY_TIMEOUT,\n  HTTP_STATUS.BAD_GATEWAY,\n  HTTP_STATUS.WEB_SERVER_IS_DOWN,\n  HTTP_STATUS.GONE,\n  HTTP_STATUS.REQUEST_TIMEOUT,\n];\n\nconst scan = async () => {\n  scanning.value = true;\n  progress.value = 0;\n\n  const totalBookmarks = await bookmarkStorage.total();\n  if (totalBookmarks === 0) {\n    scanning.value = false;\n    return;\n  }\n\n  let processed = 0;\n  let id = null;\n\n  while (scanning.value) {\n    const batch = await bookmarkStorage.findAfterId(id, PAGINATION_LIMIT);\n    if (batch.length === 0) break;\n\n    processed += batch.length;\n    progress.value = Math.ceil((processed / totalBookmarks) * 100);\n\n    const results = await Promise.all(\n      batch.map(async (bookmark) => {\n        let httpStatus = await fetchHead(bookmark.url, 15000);\n        if (httpStatus === HTTP_STATUS.NOT_FOUND) {\n          httpStatus = (await fetchUrl(bookmark.url, 15000)).httpStatus;\n        }\n        return httpStatus >= HTTP_STATUS.BAD_REQUEST\n          ? { httpStatus, id: bookmark.id }\n          : null;\n      }),\n    );\n\n    const broken = results.filter(Boolean);\n    if (broken.length) {\n      await Promise.all(\n        broken.map((r) => bookmarkStorage.updateHttpStatusById(r.id, r.httpStatus)),\n      );\n      total.value = await bookmarkStorage.getTotalByHttpStatus(httpStatuses);\n      bookmarks.value = await bookmarkStorage.findByHttpStatus(httpStatuses, 0, PAGINATION_LIMIT);\n    }\n\n    id = batch[batch.length - 1].id;\n  }\n\n  if (scanning.value) {\n    progress.value = 100;\n  }\n  scanning.value = false;\n};\n\nconst stop = () => {\n  scanning.value = false;\n  progress.value = 0;\n};\n\nconst load = async () => {\n  try {\n    loading.value = true;\n    bookmarks.value = await bookmarkStorage.findByHttpStatus(httpStatuses, 0, PAGINATION_LIMIT);\n    total.value = await bookmarkStorage.getTotalByHttpStatus(httpStatuses);\n  } catch (e) {\n    console.error(e);\n  } finally {\n    loading.value = false;\n  }\n};\n\nconst loadMore = async (offset) => {\n  try {\n    const more = await bookmarkStorage.findByHttpStatus(httpStatuses, offset, PAGINATION_LIMIT);\n    bookmarks.value.push(...more);\n  } catch (e) {\n    console.error(e);\n  }\n};\n\nconst onDelete = async (bookmark) => {\n  if (await confirmationRef.value.request() === false) {\n    return;\n  }\n  try {\n    await browser.bookmarks.remove(String(bookmark.id));\n    bookmarks.value = bookmarks.value.filter((b) => b.id !== bookmark.id);\n    total.value = await bookmarkStorage.getTotalByHttpStatus(httpStatuses);\n    notify({ group: 'default', text: 'Bookmark successfully removed!' }, NOTIFICATION_DURATION);\n  } catch (e) {\n    console.error(e);\n    notify({ group: 'error', text: 'Failed to remove bookmark. Please try again.' }, NOTIFICATION_DURATION);\n  }\n\n  try {\n    if (bookmarks.value.length < PAGINATION_LIMIT) {\n      const more = await bookmarkStorage.findByHttpStatus(httpStatuses, bookmarks.value.length, 1);\n      if (more.length) bookmarks.value.push(...more);\n    }\n  } catch (e) {\n    console.error(e);\n  }\n};\n\nonMounted(load);\nonActivated(async () => {\n  total.value = 0;\n  total.value = await bookmarkStorage.getTotalByHttpStatus(httpStatuses);\n});\n</script>\n"
  },
  {
    "path": "src/ext/browser/views/NotesView.vue",
    "content": "<template>\n  <div class=\"flex flex-col md:flex-row h-screen md:h-screen\">\n    <div class=\"w-full md:w-1/3 gap-y-3 border-b md:border-b-0 md:border-r border-gray-300 bg-white dark:border-neutral-900 dark:bg-black\">\n      <div class=\"relative w-full p-2\">\n        <input\n          id=\"title\"\n          v-model=\"searchTerm\"\n          type=\"text\"\n          placeholder=\"Search something..\"\n          class=\"h-9 w-full rounded-md border-gray-200 pl-7 text-xs text-gray-700 shadow-sm outline-none focus:border-gray-300 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white focus:dark:border-neutral-700\"\n        >\n        <span class=\"pointer-events-none absolute inset-y-0 left-1 grid w-10 place-content-center text-black dark:text-white\">\n          <PhMagnifyingGlassLight />\n        </span>\n      </div>\n      <AppInfiniteScroll\n        ref=\"scroll\"\n        class=\"h-72 md:h-full overflow-y-auto\"\n        :limit=\"PAGINATION_LIMIT\"\n        @scroll:end=\"loadMoreBookmarks\"\n      >\n        <TransitionGroup\n          enter-active-class=\"transition-opacity duration-200 ease-out\"\n          enter-from-class=\"opacity-0\"\n          enter-to-class=\"opacity-100\"\n          leave-active-class=\"transition-opacity duration-200 ease-in\"\n          leave-from-class=\"opacity-100\"\n          leave-to-class=\"opacity-0\"\n          move-class=\"transition-transform duration-200 ease-out\"\n          tag=\"ul\"\n          class=\"w-full flex flex-col cursor-pointer gap-y-2 px-2 pb-20 mt-1\"\n          role=\"listbox\"\n        >\n          <li\n            v-for=\"bookmark in bookmarks\"\n            :key=\"`${bookmark.id}-${bookmark.updatedAt}`\"\n            role=\"option\"\n            :aria-selected=\"bookmark.id === currentBookmarkId\"\n            tabindex=\"0\"\n            class=\"focus:outline-none focus-visible:ring-1 focus-visible:ring-gray-400 dark:focus-visible:ring-gray-500 rounded-md\"\n            @click=\"openEditor(bookmark)\"\n            @keydown.enter=\"openEditor(bookmark)\"\n          >\n            <PinnedCard\n              :bookmark=\"bookmark\"\n              :active=\"bookmark.id === currentBookmarkId\"\n              @open=\"open\"\n              @pin=\"unpin\"\n            />\n          </li>\n        </TransitionGroup>\n      </AppInfiniteScroll>\n    </div>\n    <div class=\"flex flex-1 flex-col overflow-y-auto bg-white px-2 dark:bg-black h-full md:h-screen\">\n      <TextEditor\n        v-if=\"currentBookmark\"\n        v-model=\"editorNotes\"\n        class=\"w-full\"\n      />\n      <div\n        v-else\n        class=\"flex flex-1 flex-col items-center justify-center p-5\"\n      >\n        <AppSpinner v-if=\"loading\" />\n        <span\n          v-else-if=\"isEmpty\"\n          class=\"text-2xl font-thin text-black dark:text-white\"\n        >\n          🗂️ Your <u>local storage</u> is currently empty. Please pin bookmarks to get started.\n        </span>\n        <span\n          v-else\n          class=\"text-2xl font-thin text-black dark:text-white\"\n        >\n          📌 Select a bookmark to start editing.\n        </span>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, watch, useTemplateRef, computed } from 'vue';\nimport { useDebounceFn } from '@vueuse/core';\nimport { notify } from 'notiwind';\nimport { PAGINATION_LIMIT, NOTIFICATION_DURATION } from '@/constants/app';\nimport BookmarkStorage from '@/storage/bookmark';\nimport AppInfiniteScroll from '@/components/app/AppInfiniteScroll.vue';\nimport PinnedCard from '@/ext/browser/components/card/PinnedCard.vue';\nimport TextEditor from '@/ext/browser/components/TextEditor.vue';\nimport AppSpinner from '@/components/app/AppSpinner.vue';\nimport PhMagnifyingGlassLight from '~icons/ph/magnifying-glass-light';\n\nconst bookmarkStorage = new BookmarkStorage();\nconst scrollRef = useTemplateRef('scroll');\n\nconst bookmarks = ref([]);\nconst searchTerm = ref('');\nconst currentBookmarkId = ref(null);\nconst editorNotes = ref('');\nconst loading = ref(true);\n\nconst currentBookmark = computed(() => {\n  if (!currentBookmarkId.value) return null;\n  return bookmarks.value.find((b) => b.id === currentBookmarkId.value) || null;\n});\n\nconst isEmpty = computed(() => !loading.value && bookmarks.value.length === 0);\n\nconst saveNotes = async (bookmarkId, notes) => {\n  try {\n    await bookmarkStorage.updateNotesById(bookmarkId, notes);\n\n    const bookmarkIndex = bookmarks.value.findIndex((b) => b.id === bookmarkId);\n    if (bookmarkIndex !== -1) {\n      bookmarks.value[bookmarkIndex].notes = notes;\n    }\n  } catch (error) {\n    console.error('Error saving notes:', error);\n    notify(\n      { group: 'error', text: 'Error saving notes.' },\n      NOTIFICATION_DURATION,\n    );\n  }\n};\n\nconst closeEditor = async () => {\n  if (currentBookmarkId.value && editorNotes.value !== undefined) {\n    await saveNotes(currentBookmarkId.value, editorNotes.value);\n  }\n  currentBookmarkId.value = null;\n  editorNotes.value = '';\n};\n\nconst loadBookmarks = async () => {\n  try {\n    loading.value = true;\n    const newBookmarks = await bookmarkStorage.findPinned(\n      0,\n      PAGINATION_LIMIT,\n      searchTerm.value,\n    );\n    bookmarks.value = newBookmarks;\n\n    if (currentBookmarkId.value && !newBookmarks.some((b) => b.id === currentBookmarkId.value)) {\n      await closeEditor();\n    }\n  } catch (error) {\n    console.error('Error loading bookmarks:', error);\n    notify(\n      { group: 'error', text: 'Error loading data.' },\n      NOTIFICATION_DURATION,\n    );\n  } finally {\n    loading.value = false;\n  }\n};\n\nconst loadMoreBookmarks = async (offset) => {\n  try {\n    const newBookmarks = await bookmarkStorage.findPinned(\n      offset,\n      PAGINATION_LIMIT,\n      searchTerm.value,\n    );\n    bookmarks.value.push(...newBookmarks);\n  } catch (error) {\n    console.error('Error loading more bookmarks:', error);\n    notify(\n      { group: 'error', text: 'Error loading data.' },\n      NOTIFICATION_DURATION,\n    );\n  }\n};\n\nconst performSearch = async () => {\n  await loadBookmarks();\n  scrollRef.value?.scrollUp();\n};\n\nconst debouncedSearch = useDebounceFn(performSearch, 300);\n\nconst open = (bookmark) => {\n  window.open(bookmark.url, '_blank');\n};\n\nconst unpin = async (bookmark) => {\n  try {\n    await bookmarkStorage.updatePinStatusById(bookmark.id, 0);\n\n    if (currentBookmarkId.value === bookmark.id) {\n      await closeEditor();\n    }\n\n    await loadBookmarks();\n  } catch (error) {\n    console.error('Error updating pin status:', error);\n    notify(\n      { group: 'error', text: 'Error updating bookmark.' },\n      NOTIFICATION_DURATION,\n    );\n  }\n};\n\nconst openEditor = async (bookmark) => {\n  const previousBookmarkId = currentBookmarkId.value;\n\n  if (previousBookmarkId && previousBookmarkId !== bookmark.id && editorNotes.value !== undefined) {\n    await saveNotes(previousBookmarkId, editorNotes.value);\n  }\n\n  currentBookmarkId.value = bookmark.id;\n  editorNotes.value = bookmark.notes || '';\n};\n\nwatch(searchTerm, debouncedSearch);\nwatch(editorNotes, (newNotes) => {\n  if (!currentBookmarkId.value) return;\n  if (newNotes === currentBookmark.value?.notes) return;\n  saveNotes(currentBookmarkId.value, newNotes);\n});\n\nonMounted(loadBookmarks);\n</script>\n"
  },
  {
    "path": "src/ext/content/content.js",
    "content": "let port;\n/**\n *\n */\nfunction connect() {\n  console.warn('Keep alive connection..');\n  port = browser.runtime.connect({ name: 'favbox' });\n  port.onDisconnect.addListener(connect);\n  port.onMessage.addListener((msg) => {\n    console.log('received', msg, 'from bg');\n  });\n  port.postMessage({ action: 'ping' });\n}\ntry {\n  connect();\n} catch (e) {\n  console.error('Content script error', e);\n}\n\nbrowser.runtime.onMessage.addListener((message, sender, sendResponse) => {\n  if (message.action === 'getHTML') {\n    const headElement = document.head;\n    const headHTML = headElement ? headElement.outerHTML : '';\n    const completeHTML = `<!DOCTYPE html><html>${headHTML}<body></body></html>`;\n    sendResponse({ html: completeHTML });\n  }\n});\n\nconsole.log('</> Content script loaded');\n"
  },
  {
    "path": "src/ext/popup/App.vue",
    "content": "<template>\n  <Suspense>\n    <PopupView />\n  </Suspense>\n</template>\n<script setup>\nimport PopupView from '@/ext/popup/PopupView.vue';\n</script>\n"
  },
  {
    "path": "src/ext/popup/PopupView.vue",
    "content": "<template>\n  <div class=\"relative inset-0 flex h-full min-h-64 min-w-96 flex-col\">\n    <div class=\"flex items-center justify-between bg-white p-3 dark:bg-black\">\n      <div class=\"flex items-center gap-3\">\n        <div class=\"flex size-12 items-center justify-center rounded-lg bg-gradient-to-r from-gray-800 to-gray-900 text-lg font-bold text-white shadow-md dark:bg-gradient-to-r dark:from-gray-100 dark:to-gray-300 dark:text-black\">\n          <RiBookmarkFill class=\"size-6\" />\n        </div>\n        <h4 class=\"font-sans text-xl font-semibold tracking-tight dark:text-white\">\n          FavBox\n        </h4>\n      </div>\n      <div class=\"group relative inline-flex items-center justify-center\">\n        <div class=\"absolute inset-0 animate-pulse rounded-md bg-gradient-to-r from-red-500 via-blue-500 to-green-500 opacity-0 blur-lg transition-all duration-500 group-hover:opacity-100 group-hover:blur-md group-hover:duration-200\" />\n        <button\n          class=\"group relative inline-flex items-center justify-center rounded-md border border-black bg-white px-4 py-2 text-xs font-medium text-black transition-all duration-200 hover:-translate-y-0.5 hover:bg-black hover:text-white hover:shadow-lg dark:border-white dark:bg-black dark:text-white dark:hover:bg-white dark:hover:text-black\"\n          @click=\"openApp\"\n        >\n          Open App\n          <RiArrowRightSLine class=\"-mr-1 ml-2 mt-0.5 size-2.5 text-black transition-colors duration-200 group-hover:text-white dark:text-white dark:group-hover:text-black\" />\n        </button>\n      </div>\n    </div>\n    <div\n      v-if=\"exists\"\n      class=\"flex grow flex-col items-center justify-center bg-white p-6 dark:bg-neutral-950\"\n    >\n      <div class=\"mb-8 flex flex-col items-center justify-center text-center\">\n        <div class=\"mb-4 flex size-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800\">\n          <LineMdConfirm class=\"size-8 text-gray-900 dark:text-white\" />\n        </div>\n        <h3 class=\"mb-2 text-lg font-semibold text-gray-900 dark:text-white\">\n          Bookmark Exists!\n        </h3>\n        <p class=\"text-sm text-gray-600 dark:text-gray-400\">\n          This page is already saved in your bookmarks\n        </p>\n      </div>\n\n      <button\n        class=\"group relative w-auto cursor-pointer overflow-hidden rounded-md border border-black bg-black p-2 px-6 text-center font-semibold text-white transition-all duration-300 hover:border-gray-800 dark:border-white dark:bg-white dark:text-black dark:hover:border-gray-200\"\n        @click=\"openAppWithBookmark\"\n      >\n        <div class=\"flex items-center gap-2\">\n          <div\n            class=\"size-2 scale-100 rounded-lg bg-white transition-all duration-300 group-hover:scale-[100.8] dark:bg-black\"\n          />\n          <span\n            class=\"inline-block whitespace-nowrap transition-all duration-300 group-hover:translate-x-12 group-hover:opacity-0\"\n          >\n            View in FavBox\n          </span>\n        </div>\n\n        <div\n          class=\"absolute top-0 z-10 flex size-full translate-x-12 items-center justify-center gap-2 text-black opacity-0 transition-all duration-300 group-hover:-translate-x-5 group-hover:opacity-100 dark:text-white\"\n        >\n          <span class=\"whitespace-nowrap\">View in FavBox</span>\n          <RiArrowRightLine class=\"size-4\" />\n        </div>\n      </button>\n    </div>\n    <div\n      v-else\n      class=\"flex grow flex-col gap-3 bg-white p-3 dark:bg-black\"\n    >\n      <div\n        v-if=\"errorMessage\"\n        class=\"flex items-center rounded bg-red-100 px-3 py-2 text-xs text-red-500 shadow-xs dark:bg-red-900 dark:text-red-200\"\n      >\n        <span>{{ errorMessage }}</span>\n      </div>\n      <BookmarkForm\n        class=\"w-full\"\n        :title=\"tab.title\"\n        :favicon=\"tab.favIconUrl\"\n        :url=\"tab.url\"\n        :folders=\"folders\"\n        :tags=\"tags\"\n        @submit=\"handleSubmit\"\n      />\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { onMounted, ref } from 'vue';\nimport BookmarkForm from '@/ext/popup/components/BookmarkForm.vue';\nimport { getFolderTree } from '@/services/browserBookmarks';\nimport BookmarkStorage from '@/storage/bookmark';\nimport LineMdConfirm from '~icons/line-md/confirm?width=24px&height=24px';\nimport RiBookmarkFill from '~icons/ri/bookmark-fill';\nimport RiArrowRightLine from '~icons/ri/arrow-right-line';\nimport RiArrowRightSLine from '~icons/ri/arrow-right-s-line';\n\nconst tags = await (new BookmarkStorage()).getTags();\nconst folders = await getFolderTree();\nconst [tab] = await browser.tabs.query({ active: true, currentWindow: true });\nconst exists = ref(false);\nconst bookmarkId = ref(null);\nconst errorMessage = ref('');\n\ntry {\n  const bookmarks = await browser.bookmarks.search({ url: tab.url });\n  exists.value = bookmarks.length > 0;\n  if (bookmarks.length > 0) {\n    const [firstBookmark] = bookmarks;\n    bookmarkId.value = firstBookmark.id;\n  }\n} catch (e) {\n  exists.value = false;\n  console.error(e);\n}\n\nconsole.debug('folders', folders);\nconsole.debug('tab', tab);\nconsole.debug('bookmarkId', bookmarkId.value);\n\nconst handleSubmit = async (data) => {\n  try {\n    errorMessage.value = '';\n    await browser.bookmarks.create({\n      title: data.title,\n      parentId: data.parentId,\n      url: data.url,\n    });\n    window.close();\n  } catch (e) {\n    errorMessage.value = \"😔 Oops, something went wrong. Please try again, or use your browser's built-in bookmark tool.\";\n    console.error(e);\n  }\n};\n\nconst openApp = () => {\n  browser.tabs.create({ url: '/ext/browser/index.html', index: tab.index + 1 });\n  window.close();\n};\n\nconst openAppWithBookmark = () => {\n  const url = `/ext/browser/index.html#/bookmarks/${bookmarkId.value}`;\n  browser.tabs.create({ url, index: tab.index + 1 });\n  window.close();\n};\n\nonMounted(async () => {\n  if (window.matchMedia('(prefers-color-scheme: dark)').matches) {\n    document.documentElement.classList.add('dark');\n  } else {\n    document.documentElement.classList.remove('dark');\n  }\n});\n</script>\n"
  },
  {
    "path": "src/ext/popup/components/BookmarkForm.vue",
    "content": "<template>\n  <form\n    @submit.prevent=\"submit\"\n  >\n    <div class=\"flex flex-col gap-y-3\">\n      <label\n        for=\"title\"\n        class=\"relative\"\n      >\n        <input\n          id=\"title\"\n          v-model=\"bookmarkTitle\"\n          required\n          type=\"text\"\n          placeholder=\"Page title\"\n          class=\"h-9 w-full rounded-md border-gray-200 pl-10 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 focus:dark:border-neutral-700\"\n        >\n        <div class=\"pointer-events-none absolute inset-y-0 left-0 grid w-10 place-content-center text-gray-700\">\n          <img\n            v-if=\"favicon\"\n            class=\"size-5\"\n            :src=\"favicon\"\n            alt=\"favicon\"\n          >\n          <PhGlobeSimpleLight\n            v-else\n            class=\"size-5\"\n          />\n        </div>\n      </label>\n      <Treeselect\n        v-model=\"selectedFolder\"\n        placeholder=\"\"\n        :before-clear-all=\"onBeforeClearAll\"\n        :always-open=\"false\"\n        :options=\"folders\"\n      />\n      <AppTagInput\n        v-model=\"selectedTags\"\n        :max=\"5\"\n        :suggestions=\"tags\"\n        placeholder=\"Tag it and press enter 🏷️\"\n      />\n      <div class=\"my-0 flex w-full justify-between\">\n        <AppButton\n          class=\"w-full\"\n        >\n          Save bookmark\n        </AppButton>\n      </div>\n    </div>\n  </form>\n</template>\n<script setup>\nimport { ref, watch } from 'vue';\n\nimport Treeselect from '@zanmato/vue3-treeselect';\nimport AppTagInput from '@/components/app/AppTagInput.vue';\nimport AppButton from '@/components/app/AppButton.vue';\nimport { joinTitleAndTags } from '@/services/tags';\nimport PhGlobeSimpleLight from '~icons/ph/globe-simple-light';\n\nconst props = defineProps({\n  folders: {\n    type: Array,\n    required: true,\n    default: () => [],\n  },\n  tags: {\n    type: Array,\n    required: true,\n    default: () => [],\n  },\n  favicon: {\n    type: String,\n    required: true,\n  },\n  title: {\n    type: String,\n    required: true,\n  },\n  url: {\n    type: String,\n    required: true,\n  },\n});\n\nconst bookmarkTitle = ref(props.title);\n\nwatch(() => props.title, (newTitle) => {\n  bookmarkTitle.value = newTitle;\n});\nconst selectedFolder = ref(1);\nconst selectedTags = ref([]);\n\nconst onBeforeClearAll = () => {\n  selectedFolder.value = 1;\n};\n\nconst emit = defineEmits(['submit']);\n\nconst submit = () => {\n  emit('submit', { title: joinTitleAndTags(bookmarkTitle.value, selectedTags.value), url: props.url, parentId: String(selectedFolder.value) });\n};\n</script>\n"
  },
  {
    "path": "src/ext/popup/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"UTF-8\" />\n  <link rel=\"icon\" href=\"/favicon.ico\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <title>Vite App</title>\n</head>\n\n<body>\n  <div id=\"app\"></div>\n  <script type=\"module\" src=\"./main.js\"></script>\n</body>\n\n</html>"
  },
  {
    "path": "src/ext/popup/main.js",
    "content": "import { createApp } from 'vue';\nimport App from './App.vue';\nimport '@/assets/app.css';\nimport '@zanmato/vue3-treeselect/dist/vue3-treeselect.min.css';\nimport '@fontsource/sn-pro';\n\ncreateApp(App).mount('#app');\n"
  },
  {
    "path": "src/ext/sw/index.js",
    "content": "import BookmarkStorage from '@/storage/bookmark';\nimport AttributeStorage from '@/storage/attribute';\nimport MetadataParser from '@/parser/metadata';\nimport { fetchUrl } from '@/services/httpClient';\nimport { extractTitle, extractTags } from '@/services/tags';\nimport { getFoldersMap, getBookmarksFromNode } from '@/services/browserBookmarks';\nimport sync from './sync';\nimport ping from './ping';\n\nconst bookmarkStorage = new BookmarkStorage();\nconst attributeStorage = new AttributeStorage();\n\n// https://developer.chrome.com/docs/extensions/develop/migrate/to-service-workers\nconst waitUntil = async (promise) => {\n  const keepAlive = setInterval(browser.runtime.getPlatformInfo, 25 * 1000);\n  try {\n    await promise;\n  } finally {\n    clearInterval(keepAlive);\n  }\n};\n\nbrowser.runtime.onInstalled.addListener(async () => {\n  browser.contextMenus.create({ id: 'openPopup', title: 'Bookmark this page', contexts: ['all'] });\n  await browser.alarms.create('healthcheck', { periodInMinutes: 0.5 });\n  await browser.storage.session.set({ nativeImport: false });\n  waitUntil(sync());\n});\n\nbrowser.runtime.onStartup.addListener(async () => {\n  console.warn('Wake up..');\n  await browser.storage.session.set({ nativeImport: false });\n  const alarm = await browser.alarms.get('healthcheck');\n  if (!alarm) {\n    await browser.alarms.create('healthcheck', { periodInMinutes: 0.5 });\n  }\n  waitUntil(sync());\n});\n\nbrowser.alarms.onAlarm.addListener(async (alarm) => {\n  if (alarm.name === 'healthcheck') {\n    console.log('health check');\n    await browser.storage.local.set({ lastHealthCheck: Date.now() });\n  }\n});\n\nbrowser.contextMenus.onClicked.addListener((info) => {\n  if (info.menuItemId === 'openPopup') {\n    browser.action.openPopup();\n  }\n});\n\n// https:// developer.browser.com/docs/extensions/reference/bookmarks/#event-onCreated\nbrowser.bookmarks.onCreated.addListener(async (id, bookmark) => {\n  const { nativeImport } = await browser.storage.session.get('nativeImport');\n  if (nativeImport === true) {\n    return;\n  }\n  console.time(`bookmark-created-${id}`);\n  console.warn('🎉 Handle bookmark create..', id, bookmark);\n  if (bookmark.url === undefined) {\n    console.warn('bad bookmark data', bookmark);\n    return;\n  }\n  let response = null;\n  let activeTab = null;\n\n  // fetch HTML from active tab (content script)\n  [activeTab] = await browser.tabs.query({ active: true });\n  try {\n    console.warn('activeTab', activeTab);\n    console.warn('requesting html from tab', activeTab);\n    const content = await browser.tabs.sendMessage(activeTab.id, { action: 'getHTML' });\n    response = { html: content?.html, error: 0 };\n    console.warn('response from tab', response);\n  } catch (e) {\n    console.error('No tabs. It is weird. Fetching data from internet.. 🌎', e);\n    response = await fetchUrl(bookmark.url, 15000);\n  }\n\n  try {\n    if (response === null) {\n      throw new Error('No page data: response is null');\n    }\n    const foldersMap = await getFoldersMap();\n    const entity = await (new MetadataParser(bookmark, response, foldersMap)).getFavboxBookmark();\n    if (entity.image === null && activeTab) {\n      try {\n        console.warn('📸 No image, take a screenshot', activeTab);\n        const screenshot = await browser.tabs.captureVisibleTab(activeTab.windowId, { format: 'jpeg', quality: 10 });\n        entity.image = screenshot;\n      } catch (e) {\n        console.error('📸', e);\n      }\n    }\n    console.log('🔖 Entity', entity);\n    await bookmarkStorage.create(entity);\n    await attributeStorage.create(entity);\n    refreshUserInterface();\n    console.log('🎉 Bookmark has been created..');\n  } catch (e) {\n    console.error('🎉', e, id, bookmark);\n  } finally {\n    response = null;\n    activeTab = null;\n  }\n  console.timeEnd(`bookmark-created-${id}`);\n});\n\n// https://developer.chrome.com/docs/extensions/reference/bookmarks/#event-onChanged\nbrowser.bookmarks.onChanged.addListener(async (id, changeInfo) => {\n  console.time(`bookmark-changed-${id}`);\n  try {\n    const [bookmark] = await browser.bookmarks.get(id);\n    // folder\n    if (!bookmark.url) {\n      // Only update if title actually changed\n      if (changeInfo.title !== undefined) {\n        await bookmarkStorage.updateBookmarksFolderName(bookmark.id, changeInfo.title);\n        console.log('🔄 Folder has been updated..', id, changeInfo);\n      }\n    }\n    // bookmark\n    if (bookmark.url) {\n      const oldBookmark = await bookmarkStorage.getById(id);\n      // Only update if title actually changed (changeInfo.title contains new value)\n      if (changeInfo.title !== undefined) {\n        await bookmarkStorage.update(id, {\n          title: extractTitle(changeInfo.title),\n          tags: extractTags(changeInfo.title),\n          url: bookmark.url,\n          updatedAt: new Date().toISOString(),\n        });\n        const newBookmark = await bookmarkStorage.getById(id);\n        if (oldBookmark && newBookmark) {\n          await attributeStorage.update(newBookmark, oldBookmark);\n        }\n      } else {\n        // Title didn't change, but url or other fields might have - update only url\n        await bookmarkStorage.update(id, {\n          url: bookmark.url,\n          updatedAt: new Date().toISOString(),\n        });\n      }\n      console.log('🔄 Bookmark has been updated..', id, changeInfo);\n    }\n  } catch (e) {\n    console.error('🔄', e, id, changeInfo);\n  }\n  refreshUserInterface();\n  console.timeEnd(`bookmark-changed-${id}`);\n});\n\n// https://developer.chrome.com/docs/extensions/reference/bookmarks/#event-onMoved\nbrowser.bookmarks.onMoved.addListener(async (id, moveInfo) => {\n  console.time(`bookmark-moved-${id}`);\n  try {\n    const [item] = await browser.bookmarks.get(id);\n    // Only process bookmarks (with url), not folders\n    if (item.url) {\n      const [folder] = await browser.bookmarks.get(moveInfo.parentId);\n      console.log('🗂 Bookmark has been moved..', id, moveInfo, folder);\n      await bookmarkStorage.update(id, {\n        folderName: folder.title,\n        folderId: folder.id,\n        updatedAt: new Date().toISOString(),\n      });\n      refreshUserInterface();\n    } else {\n      // Folder moved - update folderName for all bookmarks in this folder\n      console.log('🗂 Folder has been moved..', id, moveInfo);\n      await bookmarkStorage.updateBookmarksFolderName(id, item.title);\n      refreshUserInterface();\n    }\n  } catch (e) {\n    console.error('🗂', e, id, moveInfo);\n  }\n  console.timeEnd(`bookmark-moved-${id}`);\n});\n\n// https://developer.chrome.com/docs/extensions/reference/bookmarks/#event-onRemoved\nbrowser.bookmarks.onRemoved.addListener(async (id, removeInfo) => {\n  console.time(`bookmark-removed-${id}`);\n  console.log('🗑️ Handle remove bookmark..', id, removeInfo);\n  // folder has been deleted..\n  if (removeInfo.node.children !== undefined) {\n    try {\n      const items = getBookmarksFromNode(removeInfo.node);\n      const bookmarksToRemove = items.map((bookmark) => bookmark.id);\n      if (bookmarksToRemove.length) {\n        await bookmarkStorage.removeByIds(bookmarksToRemove);\n        // Full refresh after folder deletion\n        const [domains, tags, keywords] = await Promise.all([\n          bookmarkStorage.aggregateDomains(),\n          bookmarkStorage.aggregateTags(),\n          bookmarkStorage.aggregateKeywords(),\n        ]);\n        await attributeStorage.refreshFromAggregated(domains, tags, keywords, true);\n        console.log('🗑️ Folder has been removed..', bookmarksToRemove.length, id, removeInfo);\n      }\n      refreshUserInterface();\n    } catch (e) {\n      console.error('🗑️ Remove err', e);\n    }\n    return;\n  }\n  // single bookmark has been deleted..\n  try {\n    const bookmark = await bookmarkStorage.getById(id);\n    if (!bookmark) {\n      // Bookmark not found in storage - might have been deleted already or never synced\n      console.warn(`Bookmark with ID ${id} not found in storage, skipping removal.`);\n      return;\n    }\n    await bookmarkStorage.removeById(id);\n    await attributeStorage.remove(bookmark);\n    console.log('🗑️ Bookmark has been removed..', id, removeInfo);\n  } catch (e) {\n    console.error('🗑️', e);\n  }\n  console.timeEnd(`bookmark-removed-${id}`);\n});\n\n// https://developer.chrome.com/docs/extensions/reference/api/bookmarks#event-onImportBegan\nbrowser.bookmarks.onImportBegan.addListener(async () => {\n  console.log('📄 Import bookmarks started');\n  await browser.storage.session.set({ nativeImport: true });\n  await browser.storage.session.set({ status: false });\n});\n\n// https://developer.chrome.com/docs/extensions/reference/api/bookmarks#event-onImportEnded\nbrowser.bookmarks.onImportEnded.addListener(async () => {\n  console.log('📄 Import bookmarks ended');\n  await browser.storage.session.set({ nativeImport: false });\n  waitUntil(sync());\n});\n\nfunction refreshUserInterface() {\n  try {\n    browser.runtime.sendMessage({ action: 'refresh' });\n  } catch (e) {\n    console.error('Refresh UI listener not available', e);\n  }\n}\nping();\n"
  },
  {
    "path": "src/ext/sw/ping.js",
    "content": "const ping = () => {\n  const onMessage = (msg, port) => {\n    console.log('received', msg, 'from', port.sender);\n  };\n  const deleteTimer = (port) => {\n    if (port.timer) {\n      clearTimeout(port.timer);\n      delete port.timer;\n    }\n  };\n  const forceReconnect = (port) => {\n    console.warn('Reconnect...');\n    deleteTimer(port);\n    port.disconnect();\n  };\n\n  // https://bugs.chromium.org/p/chromium/issues/detail?id=1152255\n  // https://bugs.chromium.org/p/chromium/issues/detail?id=1189678\n  browser.runtime.onConnect.addListener((port) => {\n    if (port.name !== 'favbox') return;\n    port.onMessage.addListener(onMessage);\n    port.onDisconnect.addListener(deleteTimer);\n    port.timer = setTimeout(forceReconnect, 30000, port);\n  });\n};\n\nexport default ping;\n"
  },
  {
    "path": "src/ext/sw/sync.js",
    "content": "import { fetchUrl } from '@/services/httpClient';\nimport BookmarkStorage from '@/storage/bookmark';\nimport AttributeStorage from '@/storage/attribute';\nimport MetadataParser from '@/parser/metadata';\nimport { getBookmarksCount, getFoldersMap, getBookmarksIterator } from '@/services/browserBookmarks';\nimport hashCode from '@/services/hash';\n\nconst MAX_CONCURRENT = 50;\nconst BATCH_SIZE = 100;\nconst PROGRESS_UPDATE_INTERVAL = 3000;\n\nconst bookmarkStorage = new BookmarkStorage();\nconst attributeStorage = new AttributeStorage();\n\nconst sendProgress = (progress, savedCount) => {\n  browser.storage.session.set({ progress });\n  browser.runtime.sendMessage({ action: 'sync', data: { progress, savedCount } }).catch(() => {});\n};\n\nconst fetchPageMetadata = async (bookmark, foldersMap) => {\n  const response = await fetchUrl(bookmark.url);\n  return (new MetadataParser(bookmark, response, foldersMap)).getFavboxBookmark();\n};\n\nconst toAttribute = (key, { value, count }) => ({\n  key,\n  value: String(value).trim(),\n  id: hashCode(key, String(value).trim()),\n  count,\n});\n\nexport const refreshAttributes = async () => {\n  console.time('refreshAttributes');\n\n  await attributeStorage.clear();\n\n  const [domains, tags, keywords] = await Promise.all([\n    bookmarkStorage.aggregateDomains(),\n    bookmarkStorage.aggregateTags(),\n    bookmarkStorage.aggregateKeywords(),\n  ]);\n\n  const attributes = [\n    ...domains.map((r) => toAttribute('domain', r)),\n    ...tags.map((r) => toAttribute('tag', r)),\n    ...keywords.map((r) => toAttribute('keyword', r)),\n  ];\n\n  await attributeStorage.saveMany(attributes);\n  console.timeEnd('refreshAttributes');\n};\n\nconst sync = async () => {\n  console.time('Sync time');\n\n  const browserTotal = await getBookmarksCount();\n  const idbTotal = await bookmarkStorage.total();\n  const { status } = await browser.storage.session.get('status');\n\n  await browser.storage.session.set({ browserTotal, idbTotal });\n  console.log(`Browser: ${browserTotal}, IDB: ${idbTotal}, Status: ${status}`);\n\n  if (browserTotal === idbTotal || status) {\n    await browser.storage.session.set({ status: true });\n    console.log('Already in sync');\n    return;\n  }\n\n  await browser.storage.session.set({ status: false });\n  const [foldersMap, existingIds] = await Promise.all([\n    getFoldersMap(),\n    bookmarkStorage.getAllIds().then((ids) => new Set(ids)),\n  ]);\n\n  const browserIds = new Set();\n  const batch = [];\n  let processed = 0;\n  let savedCount = 0;\n  let lastProgressUpdate = Date.now();\n\n  const bookmarksToProcess = [];\n  for await (const bookmark of getBookmarksIterator()) {\n    browserIds.add(bookmark.id);\n    if (!existingIds.has(bookmark.id)) {\n      bookmarksToProcess.push(bookmark);\n    }\n  }\n  bookmarksToProcess.sort((a, b) => String(a.id).localeCompare(String(b.id)));\n\n  console.log(`To process: ${bookmarksToProcess.length}`);\n\n  const active = new Set();\n\n  const handleBookmark = async (bookmark) => {\n    try {\n      const result = await fetchPageMetadata(bookmark, foldersMap);\n      batch.push(result);\n      processed++;\n      const now = Date.now();\n      if (now - lastProgressUpdate > PROGRESS_UPDATE_INTERVAL) {\n        const progress = Math.round((processed / bookmarksToProcess.length) * 100);\n        sendProgress(progress, savedCount);\n        lastProgressUpdate = now;\n      }\n    } catch (error) {\n      console.error(`Error processing ${bookmark.url}:`, error.message);\n    }\n  };\n\n  for (const bookmark of bookmarksToProcess) {\n    const promise = handleBookmark(bookmark);\n\n    active.add(promise);\n    promise.finally(() => active.delete(promise));\n\n    if (active.size >= MAX_CONCURRENT) {\n      await Promise.race(active);\n    }\n\n    if (batch.length >= BATCH_SIZE) {\n      const itemsToSave = batch.splice(0, batch.length);\n      await bookmarkStorage.createMany(itemsToSave);\n      savedCount += itemsToSave.length;\n      console.log(`Saved batch: ${itemsToSave.length}, total: ${savedCount}`);\n    }\n  }\n\n  await Promise.all(active);\n  if (batch.length > 0) {\n    await bookmarkStorage.createMany(batch);\n    savedCount += batch.length;\n    console.log(`Saved final batch: ${batch.length}, total: ${savedCount}`);\n  }\n\n  const idbIds = await bookmarkStorage.getAllIds();\n  const toDelete = idbIds.filter((id) => !browserIds.has(id));\n  if (toDelete.length > 0) {\n    console.log(`Removing ${toDelete.length} outdated bookmarks`);\n    await bookmarkStorage.removeByIds(toDelete);\n  }\n\n  await refreshAttributes();\n  await browser.storage.session.set({ status: true });\n  sendProgress(100, savedCount);\n\n  console.timeEnd('Sync time');\n  console.log(`Saved: ${savedCount}`);\n};\n\nexport default sync;\n"
  },
  {
    "path": "src/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"utf-8\">\n  <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n  <meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">\n  <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-touch-icon.png\">\n  <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/favicon-32x32.png\">\n  <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicon-16x16.png\">\n  <link rel=\"manifest\" href=\"/site.webmanifest\">\n  <title>\n    FavBox\n  </title>\n  <style>\n    body {\n      height: 100vh;\n      width: 100vw;\n      display: flex;\n      justify-content: center;\n      align-items: center;\n      font-family: 'Helvetica', 'Arial', sans-serif;\n      font-size: 20vmin;\n      background: #EDDDD4;\n      color:#FFF;\n    }\n  </style>\n</head>\n\n<body>\n  <h1>FavBox</h1>\n</body>\n\n</html>"
  },
  {
    "path": "src/parser/metadata.js",
    "content": "import { parseHTML } from 'linkedom';\nimport { extractTitle, extractTags } from '@/services/tags';\n\n/**\n * Class for parsing bookmark metadata from HTML documents.\n */\nexport default class MetadataParser {\n  #bookmark;\n\n  #httpResponse;\n\n  #dom;\n\n  #folders;\n\n  /**\n   * Creates an instance of MetadataParser.\n   * @param {object} bookmark - The bookmark object from browser.\n   * @param {object} httpResponse - The HTTP response object containing HTML.\n   * @param {Map<string, string>} [folders] - Cache map of folder IDs to names.\n   */\n  constructor(bookmark, httpResponse, folders = new Map()) {\n    const { document } = parseHTML(httpResponse.html);\n    this.#bookmark = bookmark;\n    this.#dom = document;\n    this.#httpResponse = httpResponse;\n    this.#folders = folders;\n  }\n\n  /**\n   * Retrieves the title from various sources in the HTML document.\n   * @returns {string} The document title, or an empty string if not found.\n   */\n  getTitle() {\n    // Check if bookmark title is empty or whitespace\n    if (!this.#bookmark.title?.trim()) {\n      // Try document title first\n      if (this.#dom.title) {\n        return this.#dom.title;\n      }\n      const metaSelectors = [\n        'meta[property=\"og:title\"]',\n        'meta[name=\"twitter:title\"]',\n      ];\n      for (const selector of metaSelectors) {\n        const element = this.#dom.querySelector(selector);\n        if (element?.getAttribute('content')) {\n          return element.getAttribute('content');\n        }\n      }\n      const headingSelectors = ['h1', 'h2'];\n      for (const selector of headingSelectors) {\n        const element = this.#dom.querySelector(selector);\n        if (element?.textContent?.trim()) {\n          return element.textContent.trim();\n        }\n      }\n      return '';\n    }\n    return this.#bookmark.title;\n  }\n\n  /**\n   * Retrieves the description from various meta tags in the HTML document.\n   * @returns {string|null} The document description, or null if not found.\n   */\n  getDescription() {\n    const selectors = [\n      'meta[property=\"og:description\"]',\n      'meta[name=\"twitter:description\"]',\n      'meta[name=\"description\"]',\n    ];\n\n    for (const selector of selectors) {\n      const element = this.#dom.querySelector(selector);\n      if (element) {\n        return element.getAttribute('content') ?? null;\n      }\n    }\n\n    return null;\n  }\n\n  /**\n   * Searches for preview image on the page.\n   * @returns {string|null} The image URL, or null if not found.\n   * @private\n   */\n  #searchPagePreview() {\n    const htmlElem = this.#dom.querySelector([\n      'img[class*=\"hero\"]',\n      'img[class*=\"banner\"]',\n      'img[class*=\"cover\"]',\n      'img[class*=\"featured\"]',\n      'img[class*=\"preview\"]',\n      'img[id*=\"post-image\"]',\n      'article img:first-of-type',\n      'main img:first-of-type',\n      '.content img:first-of-type',\n    ].join(','));\n    const src = (htmlElem?.getAttribute('content') || htmlElem?.getAttribute('href') || htmlElem?.getAttribute('src')) ?? null;\n    return src;\n  }\n\n  /**\n   * Retrieves the Open Graph/Meta image URL from the HTML document.\n   * @returns {string|null} The URL of the Open Graph image, or null if not found.\n   * @private\n   */\n  #getImageFromMeta() {\n    const selectors = [\n      'meta[property=\"og:image\"]',\n      'meta[property=\"og:image:url\"]',\n      'meta[property=\"og:image:secure_url\"]',\n      'meta[name=\"twitter:image\"]',\n      'meta[name=\"twitter:image:src\"]',\n      'meta[name=\"image\"]',\n      'meta[name=\"og:image\"]',\n      'link[rel=\"image_src\"]',\n      'link[rel=\"preload\"][as=\"image\"]',\n      'meta[property=\"forem:logo\"]',\n    ];\n\n    for (const selector of selectors) {\n      const element = this.#dom.querySelector(selector);\n      if (element) {\n        const imageUrl = element.getAttribute('content') || element.getAttribute('href') || element.getAttribute('src') || null;\n        if (imageUrl) {\n          return imageUrl;\n        }\n      }\n    }\n\n    return null;\n  }\n\n  /**\n   * Extracts YouTube video ID from URL.\n   * @returns {string|null} The video ID, or null if not found.\n   * @private\n   */\n  #getYouTubeVideoId() {\n    const { url } = this.#bookmark;\n    if (!url) return null;\n\n    // Match various YouTube URL formats\n    const patterns = [\n      /(?:youtube\\.com\\/watch\\?v=|youtu\\.be\\/|youtube\\.com\\/embed\\/|youtube\\.com\\/v\\/)([^&\\n?#]+)/,\n    ];\n\n    for (const pattern of patterns) {\n      const match = url.match(pattern);\n      if (match?.[1]) {\n        return match[1];\n      }\n    }\n\n    return null;\n  }\n\n  /**\n   * Retrieves the main image URL from the HTML document.\n   * @returns {string|null} The URL of the main image, or null if not found.\n   */\n  getImage() {\n    const videoId = this.#getYouTubeVideoId();\n    if (videoId) {\n      return `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;\n    }\n\n    const metaImage = this.#getImageFromMeta();\n    if (metaImage) {\n      return new URL(metaImage, this.#bookmark.url).href;\n    }\n    const src = this.#searchPagePreview();\n    return src ? new URL(src, this.#bookmark.url).href : null;\n  }\n\n  /**\n   * Retrieves the domain from the bookmark URL.\n   * @returns {string} The domain of the bookmark URL.\n   */\n  getDomain() {\n    return new URL(this.#bookmark.url).hostname.replace(/^www\\./, '');\n  }\n\n  /**\n   * Retrieves the favicon URL from the HTML document.\n   * @returns {string} The URL of the favicon or a default favicon URL if not found.\n   */\n  getFavicon() {\n    let link = this.#dom.querySelector('link[rel=\"icon\"][type=\"image/svg+xml\"]')?.getAttribute('href');\n    if (!link) {\n      link = this.#dom.querySelector('link[rel=\"shortcut icon\"], link[rel=\"icon\"]')?.getAttribute('href');\n    }\n    return link ? new URL(link, this.#bookmark.url).href : `https://${this.getDomain()}/favicon.ico`;\n  }\n\n  /**\n   * Retrieves the URL of the bookmark.\n   * @returns {string} The URL of the bookmark.\n   */\n  getUrl() {\n    return this.#bookmark.url;\n  }\n\n  /**\n   * Retrieves the keywords from the HTML document's meta tags.\n   * @returns {string[]} An array of keywords or an empty array if no keywords are found.\n   */\n  getKeywords() {\n    const keywords = this.#dom.querySelector('meta[name=\"keywords\"]')?.getAttribute('content');\n    if (!keywords) return [];\n    return keywords.split(',').map((keyword) => keyword.trim().toLowerCase()).filter((keyword) => keyword.length > 0);\n  }\n\n  /**\n   * Gets folder name from cache instead of making API call.\n   * @returns {string} The folder name or 'Unknown' if not found.\n   * @private\n   */\n  #getFolderName() {\n    return this.#folders.get(this.#bookmark.parentId.toString()) || 'Unknown';\n  }\n\n  /**\n   * Builds a bookmark entity for Favbox.\n   * @returns {Promise<object>} A promise that resolves to the bookmark entity object.\n   */\n  async getFavboxBookmark() {\n    console.warn(this.getImage());\n    const entity = {\n      id: this.#bookmark.id,\n      folderId: this.#bookmark.parentId,\n      folderName: this.#getFolderName(),\n      title: extractTitle(this.#bookmark.title),\n      description: this.getDescription(),\n      favicon: this.getFavicon(),\n      image: this.getImage(),\n      domain: this.getDomain(),\n      keywords: this.getKeywords(),\n      url: this.#bookmark.url,\n      tags: extractTags(this.#bookmark.title),\n      pinned: 0,\n      notes: '',\n      httpStatus: this.#httpResponse.httpStatus,\n      dateAdded: this.#bookmark.dateAdded,\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n    };\n    return entity;\n  }\n}\n"
  },
  {
    "path": "src/services/browserBookmarks.js",
    "content": "/**\n * Counts the total number of bookmarks.\n * @returns {Promise<number>}\n */\nexport async function getBookmarksCount() {\n  const tree = await browser.bookmarks.getTree();\n  let count = 0;\n  const countBookmarks = (nodes) => {\n    nodes.forEach((node) => {\n      if (node.url) {\n        count += 1;\n      }\n      if (node.children) {\n        countBookmarks(node.children);\n      }\n    });\n  };\n  countBookmarks(tree);\n  return count;\n}\n\n/**\n * Recursively collects all bookmarks from the given node.\n * @param {object} node - The bookmark node.\n * @returns {Array<{id: string, url: string}>}\n */\nexport function getBookmarksFromNode(node) {\n  if (!node) {\n    return [];\n  }\n  const items = [];\n  if (node.url) {\n    items.push({ id: node.id, url: node.url });\n  }\n  if (node.children) {\n    for (const child of node.children) {\n      items.push(...getBookmarksFromNode(child));\n    }\n  }\n  return items;\n}\n\n/**\n * Retrieves the tree of all folders with bookmark counts.\n * @returns {Promise<Array<{id: string, label: string, count: number, children?: Array}>>}\n */\nexport async function getFolderTree() {\n  const tree = await browser.bookmarks.getTree();\n  const buildFolders = (nodes) => nodes\n    .filter((node) => node.children && !node.url)\n    .map((node) => {\n      const children = buildFolders(node.children);\n      const ownCount = node.children.filter((n) => n.url).length;\n      const childrenCount = children.reduce((sum, c) => sum + c.count, 0);\n      return {\n        id: node.id,\n        label: node.title,\n        count: ownCount + childrenCount,\n        ...(children.length > 0 && { children }),\n      };\n    });\n  return buildFolders(tree[0].children);\n}\n\n/**\n * Retrieves all browser bookmarks.\n * @yields {browser.bookmarks.BookmarkTreeNode}\n */\nexport async function* getBookmarksIterator() {\n  const bookmarksTree = await browser.bookmarks.getTree();\n  function* processNode(node) {\n    if (node.url) {\n      yield node;\n    }\n    if (node.children) {\n      for (const child of node.children) {\n        yield* processNode(child);\n      }\n    }\n  }\n  for (const rootNode of bookmarksTree) {\n    yield* processNode(rootNode);\n  }\n}\n\n/**\n * @returns {Promise<Map<string, string>>}\n */\nexport async function getFoldersMap() {\n  const foldersMap = new Map();\n  const traverseTree = (nodes) => {\n    for (const node of nodes) {\n      if (node.children) {\n        foldersMap.set(node.id, node.title);\n        traverseTree(node.children);\n      }\n    }\n  };\n  const tree = await browser.bookmarks.getTree();\n  traverseTree(tree);\n  return foldersMap;\n}\n"
  },
  {
    "path": "src/services/hash.js",
    "content": "export default function hashCode(...str) {\n  if (!str.length || !str.every((s) => typeof s === 'string')) {\n    return '0';\n  }\n  const s = str.map((v) => v.trim()).filter((v) => v).join('');\n  if (!s) {\n    return '0';\n  }\n  let h = 0;\n  for (let i = 0; i < s.length; i++) {\n    h = Math.imul(31, h) + s.charCodeAt(i) | 0;\n  }\n  return Math.abs(h).toString();\n}\n"
  },
  {
    "path": "src/services/httpClient.js",
    "content": "import { HTTP_STATUS } from '@/constants/httpStatus';\n\n/**\n * Makes an HTTP GET request with a timeout.\n * @param {string} url - The URL to fetch.\n * @param {number} [timeout] - Timeout in milliseconds (default: 20000).\n * @returns {Promise<{html: string|null, httpStatus: number}>}\n */\nexport async function fetchUrl(url, timeout = 20000) {\n  const controller = new AbortController();\n  const id = setTimeout(() => controller.abort(), timeout);\n  try {\n    const response = await fetch(url, {\n      method: 'GET',\n      mode: 'cors',\n      redirect: 'follow',\n      signal: controller.signal,\n    });\n    const text = await response.text();\n    return {\n      html: response.ok ? text : null,\n      httpStatus: response.status,\n    };\n  } catch (e) {\n    const errorCode = e.name === 'AbortError' ? HTTP_STATUS.REQUEST_TIMEOUT : HTTP_STATUS.UNKNOWN_ERROR;\n    return {\n      httpStatus: errorCode,\n      html: null,\n    };\n  } finally {\n    clearTimeout(id);\n  }\n}\n\n/**\n * Makes a HEAD HTTP request with a timeout.\n * @param {string} url - The URL to make HEAD request to.\n * @param {number} [timeout] - Timeout in milliseconds (default: 20000).\n * @returns {Promise<number>} The HTTP status code or error code (REQUEST_TIMEOUT, UNKNOWN_ERROR).\n */\nexport async function fetchHead(url, timeout = 20000) {\n  const controller = new AbortController();\n  const id = setTimeout(() => controller.abort(), timeout);\n  try {\n    const response = await fetch(url, {\n      method: 'HEAD',\n      mode: 'cors',\n      redirect: 'follow',\n      signal: controller.signal,\n    });\n    return response.status;\n  } catch (e) {\n    const errorCode = e.name === 'AbortError' ? HTTP_STATUS.REQUEST_TIMEOUT : HTTP_STATUS.UNKNOWN_ERROR;\n    return errorCode;\n  } finally {\n    clearTimeout(id);\n  }\n}\n"
  },
  {
    "path": "src/services/tags.js",
    "content": "/**\n * Joins a title and tags into a single string.\n * @param {string} title\n * @param {Array<string>} tags\n * @returns {string}\n */\nexport function joinTitleAndTags(title, tags = []) {\n  const filteredTags = (tags || []).filter(Boolean);\n  if (filteredTags.length === 0) {\n    return title || '';\n  }\n  return `${title || ''} ${String.fromCodePoint(0x1f3f7)} ${filteredTags.map((tag) => `#${tag}`).join(' ')}`;\n}\n\n/**\n * Extracts the title from a string that may contain tags.\n * @param {string} string\n * @returns {string} The extracted title.\n */\nexport function extractTitle(string) {\n  if (!string) return '';\n  return string.split(String.fromCodePoint(0x1f3f7))[0]?.trim() || '';\n}\n\n/**\n * Extracts tags from a string.\n * @param {string} string\n * @returns {Array<string>} An array of extracted tags.\n */\nexport function extractTags(string) {\n  if (!string) return [];\n  const parts = string.split(String.fromCodePoint(0x1f3f7)).map((part) => part.trim());\n  if (parts.length < 2 || parts[1].length === 0) {\n    return [];\n  }\n  return parts[1]\n    .split(/(?=#)/)\n    .map((tag) => tag.trim().replace(/^#/, ''))\n    .filter((tag) => tag && tag.length > 0 && !/^\\uFE0F+$/.test(tag));\n}\n"
  },
  {
    "path": "src/storage/attribute.js",
    "content": "import hashCode from '@/services/hash';\nimport useConnection from './idb/connection';\n\nexport default class AttributeStorage {\n  async search(includes, sortColumn = 'count', sortDirection = 'desc', term = '', skip = 0, limit = 200) {\n    const connection = await useConnection();\n    const whereConditions = {};\n    const keys = Object.entries(includes).reduce((acc, [key, value]) => {\n      if (value === true) {\n        acc.push(key);\n      }\n      return acc;\n    }, []);\n\n    if (keys.length === 0) {\n      return [];\n    }\n    Object.assign(whereConditions, { key: { in: keys } });\n    Object.assign(whereConditions, term ? { value: { like: `%${term}%` } } : {});\n\n    return connection.select({\n      from: 'attributes',\n      where: Object.keys(whereConditions).length === 0 ? null : whereConditions,\n      // distinct: true,\n      skip,\n      limit,\n      order: {\n        by: sortColumn,\n        type: sortDirection,\n      },\n    });\n  }\n\n  async filterByKeyAndValue(key, value, skip, limit = 50) {\n    const connection = await useConnection();\n    const whereConditions = [{ key }];\n    if (value) {\n      whereConditions.push({\n        value: { like: `%${value}%` },\n      });\n    }\n    return connection.select({\n      from: 'attributes',\n      distinct: true,\n      limit,\n      skip,\n      where: whereConditions,\n      order: {\n        by: 'value',\n        type: 'asc',\n      },\n    });\n  }\n\n  getAttributesFromBookmark(bookmark) {\n    const { domain = '', tags = [], keywords = [] } = bookmark;\n    const isValid = (v) => typeof v === 'string' && v.trim().length > 0;\n    return [\n      isValid(domain) && { key: 'domain', value: domain.trim(), id: hashCode('domain', domain.trim()) },\n      ...tags.filter(isValid).map((tag) => ({ key: 'tag', value: tag.trim(), id: hashCode('tag', tag.trim()) })),\n      ...keywords.filter(isValid).map((keyword) => ({ key: 'keyword', value: keyword.trim(), id: hashCode('keyword', keyword.trim()) })),\n    ].filter(Boolean);\n  }\n\n  async create(bookmark) {\n    const connection = await useConnection();\n    const allAttributes = this.getAttributesFromBookmark(bookmark);\n    if (allAttributes.length === 0) return;\n    const existing = await connection.select({\n      from: 'attributes',\n      where: { id: { in: allAttributes.map((attr) => attr.id) } },\n    });\n    const existingMap = new Map(existing.map((r) => [r.id, r.count || 0]));\n    const updatedAttributes = allAttributes.map((attr) => ({\n      ...attr,\n      count: (existingMap.get(attr.id) || 0) + 1,\n    }));\n    await connection.insert({\n      into: 'attributes',\n      upsert: true,\n      values: updatedAttributes,\n      skipDataCheck: true,\n    });\n  }\n\n  async remove(bookmark) {\n    console.log('AttributeStorage.remove', bookmark);\n    const connection = await useConnection();\n    const allAttributes = this.getAttributesFromBookmark(bookmark);\n\n    if (allAttributes.length === 0) return;\n\n    const ids = allAttributes.map((attr) => attr.id);\n    console.log(ids);\n    const existing = await connection.select({\n      from: 'attributes',\n      where: { id: { in: ids } },\n    });\n\n    const toDelete = [];\n    const toUpdate = [];\n\n    existing.forEach((record) => {\n      const newCount = (record.count || 0) - 1;\n\n      if (newCount <= 0) {\n        toDelete.push(record.id);\n      } else {\n        toUpdate.push({\n          ...record,\n          count: newCount,\n        });\n      }\n    });\n\n    if (toDelete.length > 0) {\n      await connection.remove({\n        from: 'attributes',\n        where: { id: { in: toDelete } },\n      });\n    }\n\n    if (toUpdate.length > 0) {\n      await connection.insert({\n        into: 'attributes',\n        upsert: true,\n        values: toUpdate,\n      });\n    }\n  }\n\n  /**\n   * Update attributes when bookmark changes.\n   * Removes old attributes and adds new ones incrementally.\n   * @param {object} newBookmark - Updated bookmark\n   * @param {object} oldBookmark - Previous bookmark state\n   */\n  async update(newBookmark, oldBookmark) {\n    if (oldBookmark) {\n      await this.remove(oldBookmark);\n    }\n    await this.create(newBookmark);\n  }\n\n  /**\n   * Refresh attributes from aggregated data.\n   * @param {Array} domains - Array of {field: 'domain', value: string, count: number}\n   * @param {Array} tags - Array of {field: 'tags', value: string, count: number}\n   * @param {Array} keywords - Array of {field: 'keywords', value: string, count: number}\n   * @param {boolean} truncate - Whether to clear existing attributes before inserting\n   */\n  async refreshFromAggregated(domains = [], tags = [], keywords = [], truncate = true) {\n    const connection = await useConnection();\n\n    const toAttribute = (key, { value, count }) => ({\n      key,\n      value: String(value).trim(),\n      id: hashCode(key, String(value).trim()),\n      count,\n    });\n\n    const attributes = [\n      ...domains.map((r) => toAttribute('domain', r)),\n      ...tags.map((r) => toAttribute('tag', r)),\n      ...keywords.map((r) => toAttribute('keyword', r)),\n    ];\n\n    if (truncate) {\n      await connection.clear('attributes');\n    }\n\n    if (attributes.length > 0) {\n      await connection.insert({\n        into: 'attributes',\n        values: attributes,\n        validation: false,\n        skipDataCheck: true,\n      });\n    }\n\n    return attributes;\n  }\n\n  async clear() {\n    const connection = await useConnection();\n    await connection.clear('attributes');\n  }\n\n  async saveMany(attributes) {\n    if (!attributes.length) return;\n    const connection = await useConnection();\n    await connection.insert({\n      into: 'attributes',\n      values: attributes,\n      validation: false,\n      skipDataCheck: true,\n    });\n  }\n}\n"
  },
  {
    "path": "src/storage/bookmark.js",
    "content": "import useConnection from './idb/connection';\n\nexport default class BookmarkStorage {\n  async createMany(data) {\n    const connection = await useConnection();\n    const result = await connection.insert({\n      into: 'bookmarks',\n      values: data,\n      validation: false,\n      skipDataCheck: true,\n      ignore: true,\n    });\n    return result;\n  }\n\n  async findAfterId(id, limit) {\n    const connection = await useConnection();\n    const query = {\n      from: 'bookmarks',\n      limit,\n      order: { by: 'id', type: 'asc' },\n      where: id ? { id: { '>': id } } : null,\n    };\n    return connection.select(query);\n  }\n\n  async search(query, skip = 0, limit = 50, sortDirection = 'desc') {\n    const connection = await useConnection();\n    const queryParams = {};\n    const whereConditions = [];\n    query.forEach(({ key, value }) => {\n      (queryParams[key] ??= []).push(value);\n    });\n\n    const conditions = [\n      { key: 'folder', condition: { folderId: { in: queryParams.folder } } },\n      { key: 'tag', condition: { tags: { in: queryParams.tag } } },\n      { key: 'domain', condition: { domain: { in: queryParams.domain } } },\n      { key: 'keyword', condition: { keywords: { in: queryParams.keyword } } },\n      { key: 'id', condition: { id: { in: queryParams.id } } },\n    ];\n\n    conditions.forEach(({ key, condition }) => {\n      if (queryParams[key]) {\n        whereConditions.push(condition);\n      }\n    });\n    if (queryParams?.term) {\n      const [term] = queryParams.term;\n      const regexPattern = term.split(/\\s+/).map((word) => `(?=.*${word})`).join('');\n      const regex = new RegExp(`^${regexPattern}.*$`, 'i');\n      whereConditions.push({\n        title: { regex },\n        or: {\n          description: { regex },\n          or: {\n            url: { regex },\n            or: {\n              domain: { like: `%${term}%` },\n              or: {\n                keywords: { regex },\n              },\n            },\n          },\n        },\n      });\n    }\n    if (queryParams?.dateAdded?.[0]) {\n      const [startStr, endStr] = queryParams.dateAdded[0].split('~');\n      const low = new Date(startStr).setHours(0, 0, 0, 0);\n      const high = new Date(endStr).setHours(23, 59, 59, 999);\n      whereConditions.push({\n        dateAdded: { '-': { low, high } },\n      });\n    }\n    return connection.select({\n      from: 'bookmarks',\n      distinct: true,\n      limit,\n      skip,\n      order: {\n        by: 'dateAdded',\n        type: sortDirection,\n      },\n      where: whereConditions.length === 0 ? null : whereConditions,\n    });\n  }\n\n  async total() {\n    const connection = await useConnection();\n    return connection.count({\n      from: 'bookmarks',\n    });\n  }\n\n  async create(entity) {\n    const connection = await useConnection();\n    return connection.insert({\n      into: 'bookmarks',\n      values: [entity],\n    });\n  }\n\n  async updateHttpStatusById(id, status) {\n    const connection = await useConnection();\n    return connection.update({\n      in: 'bookmarks',\n      set: {\n        httpStatus: parseInt(status, 10),\n        updatedAt: new Date().toISOString(),\n      },\n      where: {\n        id,\n      },\n    });\n  }\n\n  async setOK() {\n    const connection = await useConnection();\n    return connection.update({\n      in: 'bookmarks',\n      set: { httpStatus: 200 },\n    });\n  }\n\n  async findPinned(skip = 0, limit = 50, term = '') {\n    const connection = await useConnection();\n    const whereConditions = [{ pinned: 1 }];\n    if (term) {\n      const regexPattern = term.split(/\\s+/).map((word) => `(?=.*${word})`).join('');\n      const regex = new RegExp(`^${regexPattern}.*$`, 'i');\n      whereConditions.push({\n        notes: { regex },\n        or: {\n          title: { regex },\n          or: {\n            description: { regex },\n            or: {\n              domain: { like: `%${term}%` },\n            },\n          },\n        },\n      });\n    }\n    return connection.select({\n      from: 'bookmarks',\n      limit,\n      skip,\n      order: {\n        by: 'updatedAt',\n        type: 'desc',\n      },\n      where: whereConditions,\n    });\n  }\n\n  async updatePinStatusById(id, status) {\n    const connection = await useConnection();\n    return connection.update({\n      in: 'bookmarks',\n      set: {\n        pinned: parseInt(status, 10),\n        updatedAt: new Date().toISOString(),\n      },\n      where: {\n        id,\n      },\n    });\n  }\n\n  async update(id, data) {\n    const connection = await useConnection();\n    return connection.update({\n      in: 'bookmarks',\n      set: data,\n      where: {\n        id,\n      },\n    });\n  }\n\n  async removeByIds(ids) {\n    const connection = await useConnection();\n    const result = await connection.remove({\n      from: 'bookmarks',\n      where: {\n        id: {\n          in: ids,\n        },\n      },\n    });\n    return result;\n  }\n\n  async removeById(id) {\n    const connection = await useConnection();\n    return connection.remove({\n      from: 'bookmarks',\n      where: { id },\n    });\n  }\n\n  async getIds(ids) {\n    const connection = await useConnection();\n    const response = await connection.select({\n      from: 'bookmarks',\n      where: {\n        id: {\n          in: ids,\n        },\n      },\n    });\n    return response.map((i) => i.id);\n  }\n\n  async getByFolderId(folderId) {\n    const connection = await useConnection();\n    const response = await connection.select({\n      from: 'bookmarks',\n      limit: 1,\n      where: {\n        folderId,\n      },\n    });\n\n    return response.length === 1 ? response.shift() : null;\n  }\n\n  async updateBookmarksFolderName(folderId, folderName) {\n    const connection = await useConnection();\n    return connection.update({\n      in: 'bookmarks',\n      set: {\n        folderName,\n        updatedAt: new Date().toISOString(),\n      },\n      where: {\n        folderId,\n      },\n    });\n  }\n\n  async getById(id) {\n    const connection = await useConnection();\n    const response = await connection.select({\n      from: 'bookmarks',\n      limit: 1,\n      where: {\n        id,\n      },\n    });\n\n    return response.length === 1 ? response.shift() : null;\n  }\n\n  async getByUrl(url) {\n    const connection = await useConnection();\n    const response = await connection.select({\n      from: 'bookmarks',\n      limit: 1,\n      where: {\n        url: String(url),\n      },\n    });\n\n    return response.length === 1 ? response.shift() : null;\n  }\n\n  async getTags() {\n    const connection = await useConnection();\n    const response = await connection.select({\n      from: 'bookmarks',\n      flatten: ['tags'],\n      groupBy: 'tags',\n      order: {\n        by: 'tags',\n        type: 'asc',\n      },\n    });\n    return response.map((item) => item.tags);\n  }\n\n  async updateStatusByIds(status, ids) {\n    const connection = await useConnection();\n    return connection.update({\n      in: 'bookmarks',\n      set: {\n        httpStatus: status,\n      },\n      where: {\n        id: {\n          in: ids,\n        },\n      },\n    });\n  }\n\n  async updateNotesById(id, notes) {\n    const connection = await useConnection();\n    return connection.update({\n      in: 'bookmarks',\n      set: {\n        notes,\n        updatedAt: new Date().toISOString(),\n      },\n      where: {\n        id,\n      },\n    });\n  }\n\n  async updateImageById(id, image) {\n    const connection = await useConnection();\n    return connection.update({\n      in: 'bookmarks',\n      set: {\n        image,\n      },\n      where: {\n        id,\n      },\n    });\n  }\n\n  async findByHttpStatus(statuses, skip = 0, limit = 50) {\n    const connection = await useConnection();\n    return connection.select({\n      from: 'bookmarks',\n      limit,\n      skip,\n      order: {\n        by: 'id',\n        type: 'desc',\n      },\n      where: {\n        httpStatus: {\n          in: statuses,\n        },\n      },\n    });\n  }\n\n  async getTotalByHttpStatus(statuses) {\n    const connection = await useConnection();\n    return connection.count({\n      from: 'bookmarks',\n      where: {\n        httpStatus: {\n          in: statuses,\n        },\n      },\n    });\n  }\n\n  async getAllIds() {\n    const connection = await useConnection();\n    const response = await connection.select({\n      from: 'bookmarks',\n      columns: ['id'],\n    });\n    return response.map((i) => i.id);\n  }\n\n  async getDuplicatesGrouped(skip = 0, limit = 50) {\n    const connection = await useConnection();\n\n    // Get all URLs with the number of duplicates\n    const groupedResults = await connection.select({\n      from: 'bookmarks',\n      groupBy: 'url',\n      aggregate: {\n        count: ['id'],\n      },\n    });\n\n    // Filter only groups with duplicates (2+ bookmarks)\n    const duplicateGroups = groupedResults.filter((group) => group['count(id)'] > 1);\n\n    // Sort by url (alphabetically)\n    duplicateGroups.sort((a, b) => String(a.url).localeCompare(String(b.url)));\n\n    // Apply pagination\n    const paginatedGroups = duplicateGroups.slice(skip, skip + limit);\n\n    // Get all bookmarks for the current page in one query\n    const urls = paginatedGroups.map((group) => group.url);\n    const allBookmarks = await connection.select({\n      from: 'bookmarks',\n      where: { url: { in: urls } },\n      order: {\n        by: 'dateAdded',\n        type: 'desc',\n      },\n    });\n\n    // Group bookmarks by URL\n    const bookmarksByUrl = Object.groupBy(allBookmarks, (b) => b.url);\n\n    // Form the result\n    const groupsWithDetails = paginatedGroups.map((group) => {\n      const bookmarks = bookmarksByUrl[group.url] || [];\n      return {\n        url: group.url,\n        bookmarks,\n        count: group['count(id)'],\n        firstAdded: bookmarks[bookmarks.length - 1], // Oldest\n        lastAdded: bookmarks[0], // Newest\n      };\n    });\n\n    return {\n      groups: groupsWithDetails,\n      total: duplicateGroups.length,\n      hasMore: skip + limit < duplicateGroups.length,\n    };\n  }\n\n  async aggregateByField(field, flatten = false) {\n    const connection = await useConnection();\n    const query = {\n      from: 'bookmarks',\n      groupBy: field,\n      aggregate: { count: ['id'] },\n    };\n    if (flatten) query.flatten = [field];\n\n    const rows = await connection.select(query);\n    return rows\n      .filter((r) => r[field])\n      .map((r) => ({ field, value: r[field], count: r['count(id)'] }));\n  }\n\n  async aggregateDomains() {\n    return this.aggregateByField('domain');\n  }\n\n  async aggregateTags() {\n    return this.aggregateByField('tags', true);\n  }\n\n  async aggregateKeywords() {\n    return this.aggregateByField('keywords', true);\n  }\n}\n"
  },
  {
    "path": "src/storage/idb/connection.js",
    "content": "/* eslint-disable import/extensions */\n/* eslint-disable import/no-unresolved */\n/* eslint-disable new-cap */\nimport { Connection, DATA_TYPE } from 'jsstore';\nimport workerInjector from 'jsstore/dist/worker_injector';\nimport jsstoreWorker from 'jsstore/dist/jsstore.worker.min.js?worker';\n\nlet connection = null;\nlet isDbInitialized = false;\n\nconst createConnection = () => {\n  if (typeof Worker === 'undefined') {\n    connection = new Connection();\n    connection.addPlugin(workerInjector);\n    console.warn('Web Worker is not supported.');\n  } else {\n    console.warn('Web Worker is supported.');\n\n    connection = new Connection(new jsstoreWorker());\n  }\n  if (import.meta.env.DEV) {\n    console.warn('DEV MODE');\n    connection.logStatus = true;\n  }\n};\n\n// using string for primary key to save compatible between Firefox and Chrome\nconst getDb = () => {\n  const tblBookmarks = {\n    name: 'bookmarks',\n    columns: {\n      id: {\n        primaryKey: true,\n        autoIncrement: false,\n        dataType: DATA_TYPE.String,\n      },\n      folderId: {\n        dataType: DATA_TYPE.String,\n        enableSearch: true,\n        notNull: false,\n      },\n      folderName: {\n        dataType: DATA_TYPE.String,\n        enableSearch: true,\n      },\n      title: {\n        notNull: true,\n        dataType: DATA_TYPE.String,\n        enableSearch: true,\n      },\n      description: {\n        dataType: DATA_TYPE.String,\n        enableSearch: true,\n      },\n      domain: {\n        dataType: DATA_TYPE.String,\n        enableSearch: true,\n      },\n      url: {\n        dataType: DATA_TYPE.String,\n        enableSearch: true,\n      },\n      favicon: {\n        dataType: DATA_TYPE.String,\n        enableSearch: false,\n      },\n      keywords: {\n        dataType: DATA_TYPE.Array,\n        multiEntry: true,\n        default: [],\n        enableSearch: true,\n      },\n      image: {\n        dataType: DATA_TYPE.String,\n        enableSearch: false,\n      },\n      tags: {\n        dataType: DATA_TYPE.Array,\n        multiEntry: true,\n        default: [],\n        enableSearch: true,\n      },\n      pinned: {\n        notNull: true,\n        dataType: DATA_TYPE.Number,\n        default: 0,\n      },\n      notes: {\n        dataType: DATA_TYPE.String,\n        notNull: false,\n      },\n      httpStatus: {\n        notNull: true,\n        dataType: DATA_TYPE.Number,\n        default: 200,\n      },\n      createdAt: {\n        dataType: DATA_TYPE.String,\n        notNull: true,\n        enableSearch: true,\n      },\n      updatedAt: {\n        dataType: DATA_TYPE.String,\n        notNull: true,\n        enableSearch: true,\n      },\n      dateAdded: {\n        notNull: true,\n        dataType: DATA_TYPE.Number,\n        enableSearch: true,\n      },\n    },\n  };\n\n  const tblAttributes = {\n    name: 'attributes',\n    columns: {\n      id: {\n        primaryKey: true,\n        autoIncrement: false,\n        dataType: DATA_TYPE.String,\n      },\n      key: {\n        notNull: true,\n        dataType: DATA_TYPE.String,\n        enableSearch: true,\n      },\n      value: {\n        notNull: true,\n        dataType: DATA_TYPE.String,\n        enableSearch: true,\n      },\n      count: {\n        notNull: true,\n        dataType: DATA_TYPE.Number,\n        enableSearch: true,\n      },\n    },\n  };\n\n  const database = {\n    name: 'favbox_database_v2',\n    tables: [tblBookmarks, tblAttributes],\n  };\n  return database;\n};\n\nconst useConnection = async () => {\n  if (!connection) {\n    createConnection();\n  }\n\n  if (!isDbInitialized) {\n    await connection.initDb(getDb());\n    isDbInitialized = true;\n  }\n\n  return connection;\n};\n\nexport default useConnection;\n"
  },
  {
    "path": "tests/integration/fetch.spec.js",
    "content": "import { describe, expect, it } from 'vitest';\nimport { fetchUrl, fetchHead } from '@/services/httpClient';\nimport { HTTP_STATUS } from '@/constants/httpStatus';\n\ndescribe('HTTP Client', () => {\n  it('fetch', async () => {\n    const result = await fetchUrl('https://jsonplaceholder.typicode.com/posts/1');\n    expect(result.httpStatus).toEqual(HTTP_STATUS.OK);\n    expect(result.html).toBeTypeOf('string');\n  });\n\n  it('fetch with timeout', async () => {\n    const result = await fetchUrl('https://jsonplaceholder.typicode.com/posts/1', 1);\n    expect(result.httpStatus).toEqual(HTTP_STATUS.REQUEST_TIMEOUT);\n  });\n\n  it('head', async () => {\n    const result = await fetchHead('https://jsonplaceholder.typicode.com/posts/1');\n    expect(result).toEqual(HTTP_STATUS.OK);\n  });\n\n  it('head with timeout', async () => {\n    const result = await fetchHead('https://jsonplaceholder.typicode.com/posts/1', 1);\n    expect(result).toEqual(HTTP_STATUS.REQUEST_TIMEOUT);\n  });\n\n  it('head with bad request', async () => {\n    const result = await fetchHead('https://jsonplaceholder.typicode.com/posts/1/123');\n    expect(result).toEqual(HTTP_STATUS.NOT_FOUND);\n  });\n});\n"
  },
  {
    "path": "tests/unit/browserBookmarks.spec.js",
    "content": "import {\n  getBookmarksCount,\n  getBookmarksFromNode,\n  getFolderTree,\n  getFoldersMap,\n  getBookmarksIterator,\n} from '@/services/browserBookmarks';\nimport { describe, expect, it, vi, beforeEach } from 'vitest';\nimport browser from 'webextension-polyfill';\n\nvi.mock('webextension-polyfill', () => ({\n  default: {\n    bookmarks: {\n      getTree: vi.fn(),\n    },\n  },\n}));\nconst mockGetTree = vi.mocked(browser.bookmarks.getTree);\n\ndescribe('browserBookmarks', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    mockGetTree.mockClear();\n  });\n\n  describe('getBookmarksCount', () => {\n    it('should count bookmarks in tree', async () => {\n      const mockTree = [\n        {\n          id: '0',\n          children: [\n            {\n              id: '1',\n              title: 'Folder 1',\n              children: [\n                { id: '2', title: 'Bookmark 1', url: 'https://example.com' },\n                { id: '3', title: 'Bookmark 2', url: 'https://google.com' },\n              ],\n            },\n            {\n              id: '4',\n              title: 'Folder 2',\n              children: [\n                { id: '5', title: 'Bookmark 3', url: 'https://github.com' },\n              ],\n            },\n          ],\n        },\n      ];\n      mockGetTree.mockResolvedValue(mockTree);\n\n      const count = await getBookmarksCount();\n      expect(count).toBe(3);\n    });\n\n    it('should return 0 for empty tree', async () => {\n      mockGetTree.mockResolvedValue([{ id: '0', children: [] }]);\n      const count = await getBookmarksCount();\n      expect(count).toBe(0);\n    });\n\n    it('should handle nested folders', async () => {\n      const mockTree = [\n        {\n          id: '0',\n          children: [\n            {\n              id: '1',\n              title: 'Folder 1',\n              children: [\n                {\n                  id: '2',\n                  title: 'Subfolder',\n                  children: [\n                    { id: '3', title: 'Bookmark 1', url: 'https://example.com' },\n                  ],\n                },\n              ],\n            },\n          ],\n        },\n      ];\n      mockGetTree.mockResolvedValue(mockTree);\n      const count = await getBookmarksCount();\n      expect(count).toBe(1);\n    });\n  });\n\n  describe('getBookmarksFromNode', () => {\n    it('should extract bookmarks from node', () => {\n      const node = {\n        id: '1',\n        title: 'Folder',\n        children: [\n          { id: '2', title: 'Bookmark 1', url: 'https://example.com' },\n          { id: '3', title: 'Bookmark 2', url: 'https://google.com' },\n        ],\n      };\n      const bookmarks = getBookmarksFromNode(node);\n      expect(bookmarks).toHaveLength(2);\n      expect(bookmarks[0]).toEqual({ id: '2', url: 'https://example.com' });\n      expect(bookmarks[1]).toEqual({ id: '3', url: 'https://google.com' });\n    });\n\n    it('should handle node with url (bookmark itself)', () => {\n      const node = {\n        id: '1',\n        title: 'Bookmark',\n        url: 'https://example.com',\n      };\n      const bookmarks = getBookmarksFromNode(node);\n      expect(bookmarks).toHaveLength(1);\n      expect(bookmarks[0]).toEqual({ id: '1', url: 'https://example.com' });\n    });\n\n    it('should return empty array for null node', () => {\n      expect(getBookmarksFromNode(null)).toEqual([]);\n    });\n\n    it('should return empty array for undefined node', () => {\n      expect(getBookmarksFromNode(undefined)).toEqual([]);\n    });\n\n    it('should handle nested folders', () => {\n      const node = {\n        id: '1',\n        title: 'Folder',\n        children: [\n          {\n            id: '2',\n            title: 'Subfolder',\n            children: [\n              { id: '3', title: 'Bookmark 1', url: 'https://example.com' },\n            ],\n          },\n          { id: '4', title: 'Bookmark 2', url: 'https://google.com' },\n        ],\n      };\n      const bookmarks = getBookmarksFromNode(node);\n      expect(bookmarks).toHaveLength(2);\n    });\n\n    it('should ignore folders without url', () => {\n      const node = {\n        id: '1',\n        title: 'Folder',\n        children: [\n          { id: '2', title: 'Subfolder', children: [] },\n          { id: '3', title: 'Bookmark', url: 'https://example.com' },\n        ],\n      };\n      const bookmarks = getBookmarksFromNode(node);\n      expect(bookmarks).toHaveLength(1);\n      expect(bookmarks[0].id).toBe('3');\n    });\n  });\n\n  describe('getFolderTree', () => {\n    it('should build folder tree with counts', async () => {\n      const mockTree = [\n        {\n          id: '0',\n          children: [\n            {\n              id: '1',\n              title: 'Folder 1',\n              children: [\n                { id: '2', title: 'Bookmark 1', url: 'https://example.com' },\n                { id: '3', title: 'Bookmark 2', url: 'https://google.com' },\n              ],\n            },\n            {\n              id: '4',\n              title: 'Folder 2',\n              children: [\n                { id: '5', title: 'Bookmark 3', url: 'https://github.com' },\n              ],\n            },\n          ],\n        },\n      ];\n      mockGetTree.mockResolvedValue(mockTree);\n\n      const tree = await getFolderTree();\n      expect(tree).toHaveLength(2);\n      expect(tree[0]).toMatchObject({\n        id: '1',\n        label: 'Folder 1',\n        count: 2,\n      });\n      expect(tree[1]).toMatchObject({\n        id: '4',\n        label: 'Folder 2',\n        count: 1,\n      });\n    });\n\n    it('should handle nested folders with correct counts', async () => {\n      const mockTree = [\n        {\n          id: '0',\n          children: [\n            {\n              id: '1',\n              title: 'Folder 1',\n              children: [\n                { id: '2', title: 'Bookmark 1', url: 'https://example.com' },\n                {\n                  id: '3',\n                  title: 'Subfolder',\n                  children: [\n                    { id: '4', title: 'Bookmark 2', url: 'https://google.com' },\n                    { id: '5', title: 'Bookmark 3', url: 'https://github.com' },\n                  ],\n                },\n              ],\n            },\n          ],\n        },\n      ];\n      mockGetTree.mockResolvedValue(mockTree);\n\n      const tree = await getFolderTree();\n      expect(tree).toHaveLength(1);\n      expect(tree[0].count).toBe(3); // 1 direct + 2 from subfolder\n      expect(tree[0].children).toHaveLength(1);\n      expect(tree[0].children[0].count).toBe(2);\n    });\n\n    it('should filter out bookmarks (only folders)', async () => {\n      const mockTree = [\n        {\n          id: '0',\n          children: [\n            { id: '1', title: 'Bookmark 1', url: 'https://example.com' },\n            {\n              id: '2',\n              title: 'Folder',\n              children: [\n                { id: '3', title: 'Bookmark 2', url: 'https://google.com' },\n              ],\n            },\n          ],\n        },\n      ];\n      mockGetTree.mockResolvedValue(mockTree);\n\n      const tree = await getFolderTree();\n      expect(tree).toHaveLength(1);\n      expect(tree[0].id).toBe('2');\n    });\n\n    it('should not include children property when folder has no subfolders', async () => {\n      const mockTree = [\n        {\n          id: '0',\n          children: [\n            {\n              id: '1',\n              title: 'Folder 1',\n              children: [\n                { id: '2', title: 'Bookmark 1', url: 'https://example.com' },\n              ],\n            },\n          ],\n        },\n      ];\n      mockGetTree.mockResolvedValue(mockTree);\n\n      const tree = await getFolderTree();\n      expect(tree[0].children).toBeUndefined();\n    });\n  });\n\n  describe('getFoldersMap', () => {\n    it('should create map of folder ids to titles', async () => {\n      const mockTree = [\n        {\n          id: '0',\n          children: [\n            {\n              id: '1',\n              title: 'Folder 1',\n              children: [\n                { id: '2', title: 'Bookmark', url: 'https://example.com' },\n              ],\n            },\n            {\n              id: '3',\n              title: 'Folder 2',\n              children: [],\n            },\n          ],\n        },\n      ];\n      mockGetTree.mockResolvedValue(mockTree);\n\n      const map = await getFoldersMap();\n      expect(map).toBeInstanceOf(Map);\n      expect(map.get('1')).toBe('Folder 1');\n      expect(map.get('3')).toBe('Folder 2');\n    });\n\n    it('should handle nested folders', async () => {\n      const mockTree = [\n        {\n          id: '0',\n          children: [\n            {\n              id: '1',\n              title: 'Folder 1',\n              children: [\n                {\n                  id: '2',\n                  title: 'Subfolder',\n                  children: [],\n                },\n              ],\n            },\n          ],\n        },\n      ];\n      mockGetTree.mockResolvedValue(mockTree);\n\n      const map = await getFoldersMap();\n      expect(map.get('1')).toBe('Folder 1');\n      expect(map.get('2')).toBe('Subfolder');\n    });\n  });\n\n  describe('getBookmarksIterator', () => {\n    it('should iterate over all bookmarks', async () => {\n      const mockTree = [\n        {\n          id: '0',\n          children: [\n            {\n              id: '1',\n              title: 'Folder 1',\n              children: [\n                { id: '2', title: 'Bookmark 1', url: 'https://example.com' },\n                { id: '3', title: 'Bookmark 2', url: 'https://google.com' },\n              ],\n            },\n            { id: '4', title: 'Bookmark 3', url: 'https://github.com' },\n          ],\n        },\n      ];\n      mockGetTree.mockResolvedValue(mockTree);\n\n      const bookmarks = [];\n      for await (const bookmark of getBookmarksIterator()) {\n        bookmarks.push(bookmark);\n      }\n\n      expect(bookmarks).toHaveLength(3);\n      expect(bookmarks[0].id).toBe('2');\n      expect(bookmarks[1].id).toBe('3');\n      expect(bookmarks[2].id).toBe('4');\n    });\n\n    it('should handle empty tree', async () => {\n      mockGetTree.mockResolvedValue([{ id: '0', children: [] }]);\n\n      const bookmarks = [];\n      for await (const bookmark of getBookmarksIterator()) {\n        bookmarks.push(bookmark);\n      }\n\n      expect(bookmarks).toHaveLength(0);\n    });\n\n    it('should handle nested folders', async () => {\n      const mockTree = [\n        {\n          id: '0',\n          children: [\n            {\n              id: '1',\n              title: 'Folder',\n              children: [\n                {\n                  id: '2',\n                  title: 'Subfolder',\n                  children: [\n                    { id: '3', title: 'Bookmark', url: 'https://example.com' },\n                  ],\n                },\n              ],\n            },\n          ],\n        },\n      ];\n      mockGetTree.mockResolvedValue(mockTree);\n\n      const bookmarks = [];\n      for await (const bookmark of getBookmarksIterator()) {\n        bookmarks.push(bookmark);\n      }\n\n      expect(bookmarks).toHaveLength(1);\n      expect(bookmarks[0].id).toBe('3');\n    });\n  });\n});\n"
  },
  {
    "path": "tests/unit/hash.spec.js",
    "content": "import { describe, expect, it } from 'vitest';\nimport hashCode from '@/services/hash';\n\ndescribe('hashCode', () => {\n  it('should generate hash for single string', () => {\n    const hash = hashCode('test');\n    expect(hash).toBeTypeOf('string');\n    expect(hash).not.toBe('0');\n  });\n\n  it('should generate same hash for same input', () => {\n    const hash1 = hashCode('domain', 'example.com');\n    const hash2 = hashCode('domain', 'example.com');\n    expect(hash1).toBe(hash2);\n  });\n\n  it('should generate different hash for different inputs', () => {\n    const hash1 = hashCode('domain', 'example.com');\n    const hash2 = hashCode('domain', 'google.com');\n    expect(hash1).not.toBe(hash2);\n  });\n\n  it('should generate different hash for different keys with same value', () => {\n    const hash1 = hashCode('domain', 'test');\n    const hash2 = hashCode('tag', 'test');\n    expect(hash1).not.toBe(hash2);\n  });\n\n  it('should concatenate multiple strings', () => {\n    const hash1 = hashCode('domain', 'example.com');\n    const hash2 = hashCode('domainexample.com');\n    expect(hash1).toBe(hash2);\n  });\n\n  it('should trim whitespace from strings', () => {\n    const hash1 = hashCode('domain', 'example.com');\n    const hash2 = hashCode('domain', '  example.com  ');\n    expect(hash1).toBe(hash2);\n  });\n\n  it('should filter out empty strings', () => {\n    const hash1 = hashCode('domain', 'example.com');\n    const hash2 = hashCode('domain', '', 'example.com', '');\n    expect(hash1).toBe(hash2);\n  });\n\n  it('should return \"0\" for empty string', () => {\n    expect(hashCode('')).toBe('0');\n  });\n\n  it('should return \"0\" for array of empty strings', () => {\n    expect(hashCode('', '', '')).toBe('0');\n  });\n\n  it('should return \"0\" for no arguments', () => {\n    expect(hashCode()).toBe('0');\n  });\n\n  it('should return \"0\" for non-string arguments', () => {\n    expect(hashCode(123)).toBe('0');\n    expect(hashCode(null)).toBe('0');\n    expect(hashCode(undefined)).toBe('0');\n    expect(hashCode({})).toBe('0');\n    expect(hashCode([])).toBe('0');\n  });\n\n  it('should return \"0\" for mixed string and non-string arguments', () => {\n    expect(hashCode('test', 123)).toBe('0');\n    expect(hashCode('test', null)).toBe('0');\n    expect(hashCode('test', undefined)).toBe('0');\n  });\n\n  it('should handle special characters', () => {\n    const hash = hashCode('tag', 'test-tag_123');\n    expect(hash).toBeTypeOf('string');\n    expect(hash).not.toBe('0');\n  });\n\n  it('should handle unicode characters', () => {\n    const hash = hashCode('tag', 'тест');\n    expect(hash).toBeTypeOf('string');\n    expect(hash).not.toBe('0');\n  });\n\n  it('should always return positive number as string', () => {\n    const hash = hashCode('test');\n    expect(parseInt(hash, 10)).toBeGreaterThanOrEqual(0);\n  });\n});\n"
  },
  {
    "path": "tests/unit/metadataParser.spec.js",
    "content": "import { describe, expect, it, beforeEach } from 'vitest';\nimport MetadataParser from '@/parser/metadata';\n\ndescribe('MetadataParser', () => {\n  let mockBookmark;\n  let mockFolders;\n\n  beforeEach(() => {\n    mockBookmark = {\n      id: '123',\n      title: 'Test Bookmark',\n      url: 'https://example.com',\n      parentId: '1',\n      dateAdded: Date.now(),\n    };\n\n    mockFolders = new Map([\n      ['1', 'Test Folder'],\n      ['2', 'Another Folder'],\n    ]);\n  });\n\n  describe('constructor', () => {\n    it('should create instance with all required parameters', () => {\n      const parser = new MetadataParser(mockBookmark, { html: '<html></html>' }, mockFolders);\n      expect(parser).toBeInstanceOf(MetadataParser);\n    });\n  });\n\n  describe('getTitle', () => {\n    it('should return bookmark title when available', () => {\n      const parser = new MetadataParser({ title: 'Test Bookmark' }, { html: '<html></html>' });\n      expect(parser.getTitle()).toBe('Test Bookmark');\n    });\n\n    it('should fallback to document title when bookmark title is empty', () => {\n      const html = `\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <title>Example Page Title</title>\n            <meta property=\"og:title\" content=\"OG Title\">\n          </head>\n          <body>\n            <h1>Main Heading</h1>\n          </body>\n        </html>\n      `;\n      const parser = new MetadataParser({ title: '' }, { html });\n      expect(parser.getTitle()).toBe('Example Page Title');\n    });\n\n    it('should fallback to og:title when document title is empty', () => {\n      const html = `\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <meta property=\"og:title\" content=\"OG Title\">\n          </head>\n          <body>\n            <h1>Main Heading</h1>\n          </body>\n        </html>\n      `;\n      const parser = new MetadataParser({ title: '' }, { html });\n      expect(parser.getTitle()).toBe('OG Title');\n    });\n\n    it('should fallback to h1 when meta titles are empty', () => {\n      const html = `\n        <!DOCTYPE html>\n        <html>\n          <head></head>\n          <body>\n            <h1>Main Heading</h1>\n          </body>\n        </html>\n      `;\n      const parser = new MetadataParser({ title: '' }, { html });\n      expect(parser.getTitle()).toBe('Main Heading');\n    });\n\n    it('should return empty string when no title is found', () => {\n      const html = '<!DOCTYPE html><html><head></head><body></body></html>';\n      const parser = new MetadataParser({ title: '' }, { html });\n      expect(parser.getTitle()).toBe('');\n    });\n  });\n\n  describe('getDescription', () => {\n    it('should return first available description (og:description in this case)', () => {\n      const html = `\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <meta property=\"og:description\" content=\"OG Description\">\n            <meta name=\"description\" content=\"This is a test description\">\n          </head>\n          <body></body>\n        </html>\n      `;\n      const parser = new MetadataParser({}, { html });\n      expect(parser.getDescription()).toBe('OG Description');\n    });\n\n    it('should fallback to meta description when og:description is not available', () => {\n      const html = `\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <meta name=\"description\" content=\"This is a test description\">\n          </head>\n          <body></body>\n        </html>\n      `;\n      const parser = new MetadataParser({}, { html });\n      expect(parser.getDescription()).toBe('This is a test description');\n    });\n\n    it('should return null when no description is found', () => {\n      const html = '<!DOCTYPE html><html><head></head><body></body></html>';\n      const parser = new MetadataParser({}, { html });\n      expect(parser.getDescription()).toBeNull();\n    });\n  });\n\n  describe('getImage', () => {\n    it('should return og:image when available', () => {\n      const html = `\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <meta property=\"og:image\" content=\"https://example.com/image.jpg\">\n            <img src=\"https://example.com/hero.jpg\" class=\"hero\">\n          </head>\n          <body></body>\n        </html>\n      `;\n      const parser = new MetadataParser({ url: 'https://example.com' }, { html });\n      expect(parser.getImage()).toBe('https://example.com/image.jpg');\n    });\n\n    it('should fallback to page preview when og:image is not available', () => {\n      const html = `\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <img src=\"https://example.com/hero.jpg\" class=\"hero\">\n          </head>\n          <body></body>\n        </html>\n      `;\n      const parser = new MetadataParser({ url: 'https://example.com' }, { html });\n      expect(parser.getImage()).toBe('https://example.com/hero.jpg');\n    });\n\n    it('should return null when no image is found', () => {\n      const html = '<!DOCTYPE html><html><head></head><body></body></html>';\n      const parser = new MetadataParser({}, { html });\n      expect(parser.getImage()).toBeNull();\n    });\n\n    it('should resolve relative URLs to absolute', () => {\n      const html = `\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <meta property=\"og:image\" content=\"/relative-image.jpg\">\n          </head>\n          <body></body>\n        </html>\n      `;\n      const parser = new MetadataParser({ url: 'https://example.com' }, { html });\n      expect(parser.getImage()).toBe('https://example.com/relative-image.jpg');\n    });\n\n    it('should return YouTube thumbnail for youtube.com/watch URL', () => {\n      const html = '<!DOCTYPE html><html><head></head><body></body></html>';\n      const parser = new MetadataParser({ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, { html });\n      expect(parser.getImage()).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg');\n    });\n\n    it('should return YouTube thumbnail for youtu.be URL', () => {\n      const html = '<!DOCTYPE html><html><head></head><body></body></html>';\n      const parser = new MetadataParser({ url: 'https://youtu.be/dQw4w9WgXcQ' }, { html });\n      expect(parser.getImage()).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg');\n    });\n\n    it('should handle YouTube URL with additional parameters', () => {\n      const html = '<!DOCTYPE html><html><head></head><body></body></html>';\n      const parser = new MetadataParser({ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s&feature=share' }, { html });\n      expect(parser.getImage()).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg');\n    });\n  });\n\n  describe('getDomain', () => {\n    it('should extract domain from URL', () => {\n      const parser = new MetadataParser({ url: 'https://example.com' }, { html: '<html></html>' });\n      expect(parser.getDomain()).toBe('example.com');\n    });\n\n    it('should remove www prefix', () => {\n      const parser = new MetadataParser({ url: 'https://www.example.com' }, { html: '<html></html>' });\n      expect(parser.getDomain()).toBe('example.com');\n    });\n\n    it('should handle URLs without protocol by adding https', () => {\n      const parser = new MetadataParser({ url: 'https://example.com' }, { html: '<html></html>' });\n      expect(parser.getDomain()).toBe('example.com');\n    });\n  });\n\n  describe('getFavicon', () => {\n    it('should return favicon from link tag', () => {\n      const html = `\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <link rel=\"icon\" href=\"https://example.com/favicon.ico\">\n          </head>\n          <body></body>\n        </html>\n      `;\n      const parser = new MetadataParser({ url: 'https://example.com' }, { html });\n      expect(parser.getFavicon()).toBe('https://example.com/favicon.ico');\n    });\n\n    it('should fallback to default favicon when not found', () => {\n      const html = '<!DOCTYPE html><html><head></head><body></body></html>';\n      const parser = new MetadataParser({ url: 'https://example.com' }, { html });\n      expect(parser.getFavicon()).toBe('https://example.com/favicon.ico');\n    });\n\n    it('should prefer SVG favicon over regular favicon', () => {\n      const html = `\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <link rel=\"icon\" href=\"https://example.com/favicon.ico\">\n            <link rel=\"icon\" type=\"image/svg+xml\" href=\"https://example.com/favicon.svg\">\n          </head>\n          <body></body>\n        </html>\n      `;\n      const parser = new MetadataParser({ url: 'https://example.com' }, { html });\n      expect(parser.getFavicon()).toBe('https://example.com/favicon.svg');\n    });\n  });\n\n  describe('getKeywords', () => {\n    it('should extract keywords from meta tag', () => {\n      const html = `\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <meta name=\"keywords\" content=\"test, example, bookmark\">\n          </head>\n          <body></body>\n        </html>\n      `;\n      const parser = new MetadataParser({}, { html });\n      expect(parser.getKeywords()).toEqual(['test', 'example', 'bookmark']);\n    });\n\n    it('should return empty array when no keywords are found', () => {\n      const html = '<!DOCTYPE html><html><head></head><body></body></html>';\n      const parser = new MetadataParser({}, { html });\n      expect(parser.getKeywords()).toEqual([]);\n    });\n\n    it('should handle empty keywords content', () => {\n      const html = `\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <meta name=\"keywords\" content=\"\">\n          </head>\n          <body></body>\n        </html>\n      `;\n      const parser = new MetadataParser({}, { html });\n      expect(parser.getKeywords()).toEqual([]);\n    });\n  });\n\n  describe('getUrl', () => {\n    it('should return bookmark URL', () => {\n      const parser = new MetadataParser({ url: 'https://example.com' }, { html: '<html></html>' });\n      expect(parser.getUrl()).toBe('https://example.com');\n    });\n  });\n\n  describe('getFavboxBookmark', () => {\n    it('should return complete bookmark entity', async () => {\n      const html = `\n        <!DOCTYPE html>\n        <html>\n          <head>\n            <meta property=\"og:description\" content=\"OG Description\">\n            <meta property=\"og:image\" content=\"https://example.com/image.jpg\">\n            <meta name=\"keywords\" content=\"test, example, bookmark\">\n            <link rel=\"icon\" href=\"https://example.com/favicon.ico\">\n          </head>\n          <body></body>\n        </html>\n      `;\n      const parser = new MetadataParser(mockBookmark, { html }, mockFolders);\n      const entity = await parser.getFavboxBookmark();\n\n      expect(entity).toMatchObject({\n        id: '123',\n        folderId: '1',\n        folderName: 'Test Folder',\n        title: 'Test Bookmark',\n        description: 'OG Description',\n        favicon: 'https://example.com/favicon.ico',\n        image: 'https://example.com/image.jpg',\n        domain: 'example.com',\n        keywords: ['test', 'example', 'bookmark'],\n        url: 'https://example.com',\n        tags: [],\n        pinned: 0,\n        notes: '',\n        httpStatus: undefined,\n        dateAdded: mockBookmark.dateAdded,\n      });\n\n      expect(entity.createdAt).toBeDefined();\n      expect(entity.updatedAt).toBeDefined();\n      expect(new Date(entity.createdAt)).toBeInstanceOf(Date);\n      expect(new Date(entity.updatedAt)).toBeInstanceOf(Date);\n    });\n\n    it('should use real tag functions for title and tags processing', async () => {\n      const html = '<!DOCTYPE html><html><head></head><body></body></html>';\n      const parser = new MetadataParser(mockBookmark, { html }, mockFolders);\n      const entity = await parser.getFavboxBookmark();\n\n      expect(entity.title).toBe('Test Bookmark');\n      expect(entity.tags).toEqual([]);\n    });\n\n    it('should handle missing folder name', async () => {\n      const html = '<!DOCTYPE html><html><head></head><body></body></html>';\n      const emptyFolders = new Map();\n      const parser = new MetadataParser(mockBookmark, { html }, emptyFolders);\n      const entity = await parser.getFavboxBookmark();\n\n      expect(entity.folderName).toBe('Unknown');\n    });\n\n    it('should handle bookmark with tags in title', async () => {\n      const html = '<!DOCTYPE html><html><head></head><body></body></html>';\n      const bookmarkWithTags = { ...mockBookmark, title: `Test Bookmark ${String.fromCodePoint(0x1f3f7)} #tag1 #tag2` };\n      const parser = new MetadataParser(bookmarkWithTags, { html }, mockFolders);\n      const entity = await parser.getFavboxBookmark();\n\n      expect(entity.title).toBe('Test Bookmark');\n      expect(entity.tags).toEqual(['tag1', 'tag2']);\n    });\n\n    it('should handle bookmark with tags but no separator', async () => {\n      const html = '<!DOCTYPE html><html><head></head><body></body></html>';\n      const bookmarkWithTagsNoSeparator = { ...mockBookmark, title: 'Test Bookmark #tag1 #tag2' };\n      const parser = new MetadataParser(bookmarkWithTagsNoSeparator, { html }, mockFolders);\n      const entity = await parser.getFavboxBookmark();\n\n      expect(entity.title).toBe('Test Bookmark #tag1 #tag2');\n      expect(entity.tags).toEqual([]);\n    });\n  });\n\n  describe('error handling', () => {\n    it('should handle malformed HTML gracefully', () => {\n      const malformedHtml = '<html><head><title>Test</title><meta name=\"description\" content=\"Test\"';\n      const parser = new MetadataParser({}, { html: malformedHtml });\n      expect(parser).toBeInstanceOf(MetadataParser);\n    });\n\n    it('should handle empty HTML', () => {\n      const parser = new MetadataParser({}, { html: '' });\n      expect(parser).toBeInstanceOf(MetadataParser);\n    });\n  });\n});\n"
  },
  {
    "path": "tests/unit/tagHelper.spec.js",
    "content": "import { describe, expect, it } from 'vitest';\nimport { joinTitleAndTags, extractTags, extractTitle } from '@/services/tags';\n\ndescribe('TagHelper', () => {\n  it('should return title when tags array is empty', () => {\n    const title = 'Hello world';\n    expect(joinTitleAndTags(title, [])).toEqual(title);\n  });\n\n  it('should return title with tags', () => {\n    expect(joinTitleAndTags('Test', ['tag1'])).toEqual(\n      `Test ${String.fromCodePoint(0x1f3f7)} #tag1`,\n    );\n  });\n\n  it('should extract tags from string', () => {\n    const string = `🧪 PhpStorm Tips & Tricks ${String.fromCodePoint(\n      0x1f3f7,\n    )} #php #test`;\n    expect(extractTags(string)).toEqual(['php', 'test']);\n  });\n\n  it('should return empty array if string does not contain separator', () => {\n    const string = '🧪 PhpStorm Tips & Tricks #php #test';\n    expect(extractTags(string)).toEqual([]);\n  });\n\n  it('should return title without tags', () => {\n    const string = `Some bookmark title ${String.fromCodePoint(\n      0x1f3f7,\n    )} #php #js`;\n    expect(extractTitle(string)).toEqual('Some bookmark title');\n  });\n\n  it('should return string with tags containing spaces', () => {\n    expect(joinTitleAndTags('Hello world', ['test', 'some tag'])).toEqual(\n      `Hello world ${String.fromCodePoint(0x1f3f7)} #test #some tag`,\n    );\n  });\n\n  it('should extract tags with spaces from string', () => {\n    expect(\n      extractTags(\n        `string -  test   ${String.fromCodePoint(\n          0x1f3f7,\n        )} #hello world #qqq #test`,\n      ),\n    ).toEqual(['hello world', 'qqq', 'test']);\n  });\n\n  it('should return empty string when input is empty', () => {\n    expect(joinTitleAndTags('', [])).toEqual('');\n  });\n\n  it('should return empty array when extractTags receives empty string', () => {\n    expect(extractTags('')).toEqual([]);\n  });\n\n  it('should return empty array if string has no tags after separator', () => {\n    const string = `Test ${String.fromCodePoint(0x1f3f7)}`;\n    expect(extractTags(string)).toEqual([]);\n  });\n\n  it('should return correct title and empty tags if string only contains separator', () => {\n    const string = `Hello ${String.fromCodePoint(0x1f3f7)}`;\n    expect(extractTitle(string)).toEqual('Hello');\n    expect(extractTags(string)).toEqual([]);\n  });\n\n  it('should handle tags with special characters correctly', () => {\n    const string = `Test ${String.fromCodePoint(0x1f3f7)} #tag_one #tag-two #tag.three #tag@four`;\n    expect(extractTags(string)).toEqual(['tag_one', 'tag-two', 'tag.three', 'tag@four']);\n  });\n\n  it('should trim extra spaces around tags', () => {\n    const string = `  Test   ${String.fromCodePoint(0x1f3f7)}   #tag1   #tag2 `;\n    expect(extractTags(string)).toEqual(['tag1', 'tag2']);\n  });\n\n  it('should return title if tags array contains only empty strings', () => {\n    expect(joinTitleAndTags('Title', ['', ''])).toEqual('Title');\n  });\n\n  it('should filter out falsy values from tags array', () => {\n    expect(joinTitleAndTags('Title', ['tag1', '', 'tag2', null, undefined])).toEqual(\n      `Title ${String.fromCodePoint(0x1f3f7)} #tag1 #tag2`,\n    );\n  });\n});\n"
  },
  {
    "path": "vite.config.firefox.js",
    "content": "import { defineConfig } from 'vite';\nimport vue from '@vitejs/plugin-vue';\nimport { resolve } from 'path';\nimport { crx } from '@crxjs/vite-plugin';\nimport Icons from 'unplugin-icons/vite';\nimport AutoImport from 'unplugin-auto-import/vite';\nimport manifest from './manifest.firefox.json';\n\nexport default defineConfig({\n  plugins: [\n    vue(),\n    crx({ browser: 'firefox', manifest }),\n    Icons({\n      autoInstall: true,\n    }),\n    AutoImport({\n      imports: [\n        {\n          'webextension-polyfill': [['*', 'browser']],\n        },\n      ],\n    }),\n  ],\n  resolve: {\n    alias: {\n      '@': resolve(__dirname, 'src'),\n    },\n  },\n  root: resolve(__dirname, 'src'),\n  publicDir: resolve(__dirname, 'public'),\n  build: {\n    outDir: resolve(__dirname, 'dist/firefox'),\n    rollupOptions: {\n      input: {\n        app: '/ext/browser/index.html',\n      },\n    },\n    minify: 'terser',\n    sourcemap: false,\n    // https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements\n    terserOptions: {\n      mangle: false,\n      compress: {\n        drop_console: false,\n        drop_debugger: false,\n      },\n    },\n  },\n  server: {\n    port: 5173,\n    strictPort: true,\n    hmr: {\n      port: 5173,\n    },\n  },\n});\n"
  },
  {
    "path": "vite.config.js",
    "content": "import { defineConfig } from 'vite';\nimport vue from '@vitejs/plugin-vue';\nimport { resolve } from 'path';\nimport { crx } from '@crxjs/vite-plugin';\nimport Icons from 'unplugin-icons/vite';\nimport AutoImport from 'unplugin-auto-import/vite';\nimport tailwindcss from '@tailwindcss/vite';\nimport manifest from './manifest.chrome.json';\n\nexport default defineConfig({\n  plugins: [\n    vue(),\n    tailwindcss(),\n    crx({ manifest }),\n    Icons({\n      autoInstall: true,\n    }),\n    AutoImport({\n      imports: [\n        {\n          'webextension-polyfill': [['default', 'browser']],\n        },\n      ],\n      dts: false,\n    }),\n  ],\n  optimizeDeps: {\n    include: ['webextension-polyfill', 'unplugin-icons', 'unplugin-auto-import'],\n  },\n  resolve: {\n    alias: {\n      '@': resolve(__dirname, 'src'),\n    },\n  },\n  root: resolve(__dirname, 'src'),\n  publicDir: resolve(__dirname, 'public'),\n  build: {\n    outDir: resolve(__dirname, 'dist/chrome'),\n    rollupOptions: {\n      input: {\n        app: '/ext/browser/index.html',\n      },\n    },\n    minify: 'terser',\n    sourcemap: false,\n    // https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements\n    terserOptions: {\n      mangle: false,\n      compress: {\n        drop_console: true,\n        drop_debugger: true,\n      },\n    },\n  },\n  server: {\n    port: 5173,\n    strictPort: true,\n    hmr: {\n      port: 5173,\n    },\n  },\n  test: {\n    cache: false,\n  },\n});\n"
  }
]