├── icons ├── icon16.png ├── icon32.png ├── icon48.png ├── icon128.png ├── icon16.svg ├── icon32.svg ├── icon48.svg └── icon128.svg ├── .gitignore ├── assets └── screenshots │ ├── en │ ├── ai_en.png │ ├── newtab_en.png │ ├── deadlinks_en.png │ ├── navigation_en.png │ ├── organize_ai_en.png │ ├── classification_en.png │ ├── organize_deadlinks_en.png │ └── organize_classification_en.png │ ├── ru │ ├── ai_ru.png │ ├── newtab_ru.png │ ├── deadlinks_ru.png │ ├── navigation_ru.png │ ├── organize_ai_ru.png │ ├── classification_ru.png │ ├── organize_deadlinks_ru.png │ └── organize_classification_ru.png │ ├── zh-CN │ ├── ai_zh-CN.png │ ├── newtab_zh-CN.png │ ├── deadlinks_zh-CN.png │ ├── navigation_zh-CN.png │ ├── organize_ai_zh-CN.png │ ├── classification_zh-CN.png │ ├── organize_deadlinks_zh-CN.png │ └── organize_classification_zh-CN.png │ └── zh-TW │ ├── ai_zh-TW.png │ ├── newtab_zh-TW.png │ ├── deadlinks_zh-TW.png │ ├── navigation_zh-TW.png │ ├── organize_ai_zh-TW.png │ ├── classification_zh-TW.png │ ├── organize_deadlinks_zh-TW.png │ └── organize_classification_zh-TW.png ├── extensions └── organize │ ├── icons │ ├── icon16.png │ ├── icon32.png │ ├── icon48.png │ ├── icon128.png │ ├── icon16.svg │ ├── icon32.svg │ ├── icon48.svg │ └── icon128.svg │ ├── _locales │ ├── zh_CN │ │ └── messages.json │ ├── zh_TW │ │ └── messages.json │ ├── en │ │ └── messages.json │ └── ru │ │ └── messages.json │ ├── src │ └── pages │ │ └── options │ │ └── overlay.js │ ├── manifest.json │ └── services │ ├── bookmarkService.js │ ├── storageService.js │ └── cloudSyncService.js ├── _locales ├── zh_CN │ └── messages.json ├── zh_TW │ └── messages.json ├── en │ └── messages.json └── ru │ └── messages.json ├── package.json ├── LICENSE ├── src └── pages │ ├── reset │ └── index.html │ ├── newtab │ ├── index.html │ └── index.css │ ├── search │ ├── index.html │ └── index.js │ └── preview │ └── index.html ├── .github └── workflows │ └── release.yml ├── README.zh-CN.md ├── manifest.json ├── scripts ├── sync_shared.mjs └── capture_screenshots.mjs ├── README.md ├── PRIVACY.md ├── services ├── bookmarkService.js ├── storageService.js └── cloudSyncService.js └── CHANGELOG.md /icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/icons/icon16.png -------------------------------------------------------------------------------- /icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/icons/icon32.png -------------------------------------------------------------------------------- /icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/icons/icon48.png -------------------------------------------------------------------------------- /icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/icons/icon128.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | playwright/.cache/ 3 | claude.md 4 | .claude 5 | .DS_Store 6 | dist -------------------------------------------------------------------------------- /assets/screenshots/en/ai_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/en/ai_en.png -------------------------------------------------------------------------------- /assets/screenshots/ru/ai_ru.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/ru/ai_ru.png -------------------------------------------------------------------------------- /assets/screenshots/en/newtab_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/en/newtab_en.png -------------------------------------------------------------------------------- /assets/screenshots/ru/newtab_ru.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/ru/newtab_ru.png -------------------------------------------------------------------------------- /extensions/organize/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/extensions/organize/icons/icon16.png -------------------------------------------------------------------------------- /extensions/organize/icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/extensions/organize/icons/icon32.png -------------------------------------------------------------------------------- /extensions/organize/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/extensions/organize/icons/icon48.png -------------------------------------------------------------------------------- /assets/screenshots/en/deadlinks_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/en/deadlinks_en.png -------------------------------------------------------------------------------- /assets/screenshots/en/navigation_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/en/navigation_en.png -------------------------------------------------------------------------------- /assets/screenshots/ru/deadlinks_ru.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/ru/deadlinks_ru.png -------------------------------------------------------------------------------- /assets/screenshots/ru/navigation_ru.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/ru/navigation_ru.png -------------------------------------------------------------------------------- /assets/screenshots/zh-CN/ai_zh-CN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-CN/ai_zh-CN.png -------------------------------------------------------------------------------- /assets/screenshots/zh-TW/ai_zh-TW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-TW/ai_zh-TW.png -------------------------------------------------------------------------------- /extensions/organize/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/extensions/organize/icons/icon128.png -------------------------------------------------------------------------------- /assets/screenshots/en/organize_ai_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/en/organize_ai_en.png -------------------------------------------------------------------------------- /assets/screenshots/ru/organize_ai_ru.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/ru/organize_ai_ru.png -------------------------------------------------------------------------------- /assets/screenshots/zh-CN/newtab_zh-CN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-CN/newtab_zh-CN.png -------------------------------------------------------------------------------- /assets/screenshots/zh-TW/newtab_zh-TW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-TW/newtab_zh-TW.png -------------------------------------------------------------------------------- /assets/screenshots/en/classification_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/en/classification_en.png -------------------------------------------------------------------------------- /assets/screenshots/ru/classification_ru.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/ru/classification_ru.png -------------------------------------------------------------------------------- /assets/screenshots/zh-CN/deadlinks_zh-CN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-CN/deadlinks_zh-CN.png -------------------------------------------------------------------------------- /assets/screenshots/zh-TW/deadlinks_zh-TW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-TW/deadlinks_zh-TW.png -------------------------------------------------------------------------------- /assets/screenshots/zh-CN/navigation_zh-CN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-CN/navigation_zh-CN.png -------------------------------------------------------------------------------- /assets/screenshots/zh-CN/organize_ai_zh-CN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-CN/organize_ai_zh-CN.png -------------------------------------------------------------------------------- /assets/screenshots/zh-TW/navigation_zh-TW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-TW/navigation_zh-TW.png -------------------------------------------------------------------------------- /assets/screenshots/zh-TW/organize_ai_zh-TW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-TW/organize_ai_zh-TW.png -------------------------------------------------------------------------------- /assets/screenshots/en/organize_deadlinks_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/en/organize_deadlinks_en.png -------------------------------------------------------------------------------- /assets/screenshots/ru/organize_deadlinks_ru.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/ru/organize_deadlinks_ru.png -------------------------------------------------------------------------------- /assets/screenshots/zh-CN/classification_zh-CN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-CN/classification_zh-CN.png -------------------------------------------------------------------------------- /assets/screenshots/zh-TW/classification_zh-TW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-TW/classification_zh-TW.png -------------------------------------------------------------------------------- /assets/screenshots/en/organize_classification_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/en/organize_classification_en.png -------------------------------------------------------------------------------- /assets/screenshots/ru/organize_classification_ru.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/ru/organize_classification_ru.png -------------------------------------------------------------------------------- /assets/screenshots/zh-CN/organize_deadlinks_zh-CN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-CN/organize_deadlinks_zh-CN.png -------------------------------------------------------------------------------- /assets/screenshots/zh-TW/organize_deadlinks_zh-TW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-TW/organize_deadlinks_zh-TW.png -------------------------------------------------------------------------------- /assets/screenshots/zh-CN/organize_classification_zh-CN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-CN/organize_classification_zh-CN.png -------------------------------------------------------------------------------- /assets/screenshots/zh-TW/organize_classification_zh-TW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhan2020/TidyMark/master/assets/screenshots/zh-TW/organize_classification_zh-TW.png -------------------------------------------------------------------------------- /_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { "message": "TidyMark" }, 3 | "appShortName": { "message": "TidyMark" }, 4 | "appDesc": { "message": "简洁新标签页:书签导航、智能分类、壁纸与天气、默认搜索。" }, 5 | "actionTitle": { "message": "TidyMark - 书签管理" } 6 | } -------------------------------------------------------------------------------- /_locales/zh_TW/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { "message": "TidyMark" }, 3 | "appShortName": { "message": "TidyMark" }, 4 | "appDesc": { "message": "簡潔新分頁:書籤導覽、智慧分類、桌布與天氣、預設搜尋。" }, 5 | "actionTitle": { "message": "TidyMark - 書籤管理" } 6 | } -------------------------------------------------------------------------------- /extensions/organize/_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { "message": "TidyMark" }, 3 | "appShortName": { "message": "TidyMark" }, 4 | "appDesc": { "message": "简洁新标签页:书签导航、智能分类、壁纸与天气、默认搜索。" }, 5 | "actionTitle": { "message": "TidyMark - 书签管理" } 6 | } -------------------------------------------------------------------------------- /extensions/organize/_locales/zh_TW/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { "message": "TidyMark" }, 3 | "appShortName": { "message": "TidyMark" }, 4 | "appDesc": { "message": "簡潔新分頁:書籤導覽、智慧分類、桌布與天氣、預設搜尋。" }, 5 | "actionTitle": { "message": "TidyMark - 書籤管理" } 6 | } -------------------------------------------------------------------------------- /_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { "message": "TidyMark" }, 3 | "appShortName": { "message": "TidyMark" }, 4 | "appDesc": { "message": "Minimal new tab: bookmark navigation, smart categorization, wallpaper & weather, default search." }, 5 | "actionTitle": { "message": "TidyMark — Bookmark Manager" } 6 | } -------------------------------------------------------------------------------- /_locales/ru/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { "message": "TidyMark" }, 3 | "appShortName": { "message": "TidyMark" }, 4 | "appDesc": { "message": "Минималистичная новая вкладка: навигация по закладкам, умная категоризация, обои и погода, поиск по умолчанию." }, 5 | "actionTitle": { "message": "TidyMark — менеджер закладок" } 6 | } -------------------------------------------------------------------------------- /extensions/organize/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { "message": "TidyMark" }, 3 | "appShortName": { "message": "TidyMark" }, 4 | "appDesc": { "message": "Minimal new tab: bookmark navigation, smart categorization, wallpaper & weather, default search." }, 5 | "actionTitle": { "message": "TidyMark — Bookmark Manager" } 6 | } -------------------------------------------------------------------------------- /extensions/organize/_locales/ru/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { "message": "TidyMark" }, 3 | "appShortName": { "message": "TidyMark" }, 4 | "appDesc": { "message": "Минималистичная новая вкладка: навигация по закладкам, умная категоризация, обои и погода, поиск по умолчанию." }, 5 | "actionTitle": { "message": "TidyMark — менеджер закладок" } 6 | } -------------------------------------------------------------------------------- /icons/icon16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /extensions/organize/icons/icon16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tidymark-screenshots", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "devDependencies": { 7 | "playwright": "^1.47.0" 8 | }, 9 | "scripts": { 10 | "shots:install": "npx playwright install chromium", 11 | "shots:serve": "npx --yes http-server . -p 5500 -c-1 --silent", 12 | "shots:full": "node scripts/capture_screenshots.mjs --variant=full --base=http://localhost:5500 --width=1280 --height=800 --langs=zh-CN,zh-TW,en,ru --out=assets/screenshots", 13 | "shots:organize": "node scripts/capture_screenshots.mjs --variant=organize --base=http://localhost:5500 --width=1280 --height=800 --langs=zh-CN,zh-TW,en,ru --out=assets/screenshots", 14 | "shots": "npm run shots:full", 15 | "sync:organize": "node scripts/sync_shared.mjs" 16 | } 17 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Hywel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /icons/icon32.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /extensions/organize/icons/icon32.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/pages/reset/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 重置 TidyMark 5 | 24 | 25 | 26 |

重置 TidyMark

27 |

点击下面的按钮清除使用记录,重新显示首次使用引导

28 | 29 | 30 | 31 | 41 | 42 | -------------------------------------------------------------------------------- /extensions/organize/src/pages/options/overlay.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | function removeNavElements() { 3 | try { 4 | const navTabBtn = document.querySelector('.tabs .tab-btn[data-tab="nav"]'); 5 | if (navTabBtn) navTabBtn.remove(); 6 | const navSection = document.getElementById('nav'); 7 | if (navSection) navSection.remove(); 8 | const activeBtn = document.querySelector('.tabs .tab-btn.active'); 9 | if (!activeBtn) { 10 | const organizeBtn = document.querySelector('.tabs .tab-btn[data-tab="organize"]'); 11 | if (organizeBtn) organizeBtn.classList.add('active'); 12 | } 13 | const activeContent = document.querySelector('.tab-content.active'); 14 | if (!activeContent) { 15 | const organizeContent = document.getElementById('organize'); 16 | if (organizeContent) organizeContent.classList.add('active'); 17 | } 18 | } catch (e) {} 19 | } 20 | function patchOptionsManager() { 21 | try { 22 | if (typeof optionsManager !== 'undefined' && optionsManager) { 23 | optionsManager.updateWidgetConfig = function() {}; 24 | } 25 | } catch (e) {} 26 | } 27 | function hideHelpQuickSearchToggle() { 28 | try { 29 | const toggle = document.getElementById('quickSearchShortcutEnabled'); 30 | if (toggle) { 31 | const card = toggle.closest('.info-card'); 32 | if (card) { 33 | card.remove(); 34 | } else { 35 | const row = toggle.closest('.setting-item'); 36 | if (row) row.style.display = 'none'; 37 | } 38 | } 39 | } catch (e) {} 40 | } 41 | document.addEventListener('DOMContentLoaded', () => { 42 | removeNavElements(); 43 | patchOptionsManager(); 44 | hideHelpQuickSearchToggle(); 45 | }); 46 | })(); 47 | -------------------------------------------------------------------------------- /icons/icon48.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /extensions/organize/icons/icon48.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /extensions/organize/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "TidyMark · 纯书签整理版", 4 | "short_name": "TidyMark · 书签整理", 5 | "version": "1.4.27", 6 | "description": "__MSG_appDesc__", 7 | "default_locale": "en", 8 | 9 | "permissions": [ 10 | "bookmarks", 11 | "storage", 12 | "downloads", 13 | "alarms", 14 | "contextMenus", 15 | "notifications" 16 | ], 17 | "host_permissions": [ 18 | "https://api.github.com/*", 19 | "https://raw.githubusercontent.com/*", 20 | "https://dns.google/*", 21 | "https://cloudflare-dns.com/*", 22 | "https://dns.alidns.com/*" 23 | ], 24 | "optional_host_permissions": [ 25 | "", 26 | "http://localhost:*/*", 27 | "https://api.openai.com/*", 28 | "https://api.deepseek.com/*" 29 | ], 30 | 31 | "action": { 32 | "default_title": "__MSG_actionTitle__", 33 | "default_icon": { 34 | "16": "icons/icon16.png", 35 | "32": "icons/icon32.png", 36 | "48": "icons/icon48.png", 37 | "128": "icons/icon128.png" 38 | } 39 | }, 40 | 41 | "background": { 42 | "service_worker": "src/background/index.js", 43 | "type": "module" 44 | }, 45 | 46 | 47 | 48 | "options_page": "src/pages/options/index.html", 49 | 50 | "icons": { 51 | "16": "icons/icon16.png", 52 | "32": "icons/icon32.png", 53 | "48": "icons/icon48.png", 54 | "128": "icons/icon128.png" 55 | }, 56 | 57 | "content_security_policy": { 58 | "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:* https://dns.google https://cloudflare-dns.com https://dns.alidns.com https://api.openai.com https://api.deepseek.com https://api.anthropic.com https://api.groq.com https://api.perplexity.ai https://openrouter.ai https://api.mistral.ai https://api.cohere.ai https://api.together.xyz https://integrate.api.nvidia.com https://api.fireworks.ai https://api.moonshot.cn https://api.minimax.chat https://open.bigmodel.cn https://dashscope.aliyuncs.com https://ark.cn-beijing.volces.com https://api.siliconflow.cn https://api.siliconflow.ai" 59 | } 60 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | workflow_dispatch: 8 | inputs: 9 | tag: 10 | description: 'Tag to release (e.g., v1.0.0)' 11 | required: true 12 | type: string 13 | 14 | permissions: 15 | contents: write 16 | 17 | jobs: 18 | build-and-release: 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | 26 | - name: Determine tag name 27 | id: vars 28 | run: | 29 | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then 30 | echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV 31 | else 32 | echo "TAG_NAME=${GITHUB_REF##*/}" >> $GITHUB_ENV 33 | fi 34 | 35 | - name: Sync organize variant 36 | run: | 37 | node scripts/sync_shared.mjs 38 | 39 | - name: Package extensions 40 | run: | 41 | mkdir -p dist 42 | # Full variant from repository root 43 | zip -r "dist/tidymark-full-${TAG_NAME}.zip" \ 44 | manifest.json \ 45 | icons \ 46 | _locales \ 47 | src \ 48 | services \ 49 | README.md LICENSE 50 | # Organize-only variant from extensions/organize 51 | cd extensions/organize 52 | zip -r "../../dist/tidymark-organize-${TAG_NAME}.zip" \ 53 | manifest.json \ 54 | icons \ 55 | _locales \ 56 | src \ 57 | services 58 | cd ../.. 59 | 60 | - name: Create GitHub Release 61 | uses: softprops/action-gh-release@v1 62 | with: 63 | tag_name: ${{ env.TAG_NAME }} 64 | name: TidyMark ${{ env.TAG_NAME }} 65 | files: | 66 | dist/tidymark-full-${{ env.TAG_NAME }}.zip 67 | dist/tidymark-organize-${{ env.TAG_NAME }}.zip 68 | draft: false 69 | prerelease: false 70 | generate_release_notes: true 71 | token: ${{ secrets.GITHUB_TOKEN }} 72 | env: 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /icons/icon128.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /extensions/organize/icons/icon128.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | 2 | [English](./README.md) | [中文] 3 | 4 | # TidyMark — 智能书签整理扩展 5 | 6 | 一个轻量的 Chrome/Edge 扩展,支持自动分类、AI 辅助整理、失效书签检测,以及新标签页导航。基于 Manifest V3 原生实现。 7 | 8 | ## 功能简介 9 | 10 | - 自动分类:按规则一键整理书签到对应类别。 11 | - AI 辅助:支持 OpenAI/DeepSeek(兼容接口)与自定义提供商,提升分类效果。 12 | - 失效书签:扫描不可访问链接,支持批量删除或移动;可限定扫描指定文件夹,并可忽略内网/本地地址(127.0.0.1、localhost、10.x、192.168.x、172.16–31.x);支持 DNS 检测(DoH)。 13 | - 新标签页导航:在新标签页展示分类导航与常用信息。 14 | - 书签云同步 / 导出:支持每日自动进行 GitHub 书签备份(可在设置页配置),也可手动创建本地备份导出;支持 WebDAV 与 Google Drive 同步/备份。 15 | - 自动归档旧书签:按“最近访问时间”判断,将不常访问的书签移入“归档”文件夹(可设置阈值,默认 180 天;无访问记录时回退按添加时间)。 16 | - 访问频率统计 / 使用热度分析:记录新标签页的书签访问次数与最近访问时间,支持热门栏目展示与基础使用分析。 17 | - 右键菜单集成:在网页右键菜单中一键“添加到 TidyMark 并分类”,自动创建并移动到匹配分类文件夹。 18 | - 快捷键快速搜索(完整版):支持以快捷键打开快速搜索页,默认 Alt+K(macOS 建议 Command+Shift+K);可在“帮助”页开关该功能。 19 | 20 | ## 安装与版本选择 21 | 22 | > 注意:安装完成后首次打开新标签页,浏览器可能弹出“是否保持由扩展设置的新标签页”的提示(Chrome/Edge)。如果你不需要导航页功能,请选择“拒绝 / 恢复默认”。这不会影响书签整理、分类、备份等核心功能,浏览器新标签页将保持默认样式。 23 | 24 | ### 版本 25 | 26 | - 纯书签版(默认推荐):不覆盖新标签页,不包含导航 UI;保留书签整理、分类、失效书签、备份与 GitHub 云同步等核心功能。下载:GitHub Releases 中的 `tidymark-organize-<版本号>.zip`,或开发者模式加载 `extensions/organize/`。 27 | - 商店安装(纯书签版): 28 | - Chrome 应用商店:https://chromewebstore.google.com/detail/pbpfkmnamjpcomlcbdjhbgcpijfafiai?utm_source=item-share-cb 29 | - Edge 扩展商店:https://microsoftedge.microsoft.com/addons/detail/dhpcgmaljdomhglcfjijpnhmaeppppfa 30 | - 完整版(含导航):包含新标签页导航与导航设置,其余功能一致。商店安装或 GitHub Releases 均可。 31 | - 商店安装(完整版): 32 | - Chrome 应用商店:https://chromewebstore.google.com/detail/tidymark/kfjmkmodmoabhcmgeojbnjbipgiknfkb?utm_source=item-share-cb 33 | - Edge 扩展商店:https://microsoftedge.microsoft.com/addons/detail/tidymark/ndfhjpodnchjkgpheaompahphpknmpjp 34 | 35 | - GitHub Releases(两版均提供):https://github.com/PanHywel/TidyMark/releases 36 | 37 | **说明:如果不需要「新标签页导航」功能,建议选择“纯书签版”;或在完整版中前往「设置 → 导航设置」保持未开启。** 38 | 39 | ### 开发者模式安装 40 | 41 | - 下载并解压 GitHub Releases 的压缩包(ZIP)。 42 | - 打开 `chrome://extensions/` 或 `edge://extensions/`。 43 | - 开启“开发者模式”,点击“加载已解压的扩展程序”,选择解压后的 `tidymark-organize-...`(纯书签版)或 `tidymark-full-...`(完整版)文件夹。 44 | 45 | ## 界面截图 46 | 47 | 新标签页(简体中文) 48 | 设置—导航(简体中文) 49 | 分类规则(简体中文) 50 | 失效书签(简体中文) 51 | AI 设置(简体中文) 52 | 53 | — 仅保留核心信息,更多细节请参考源码与注释。 54 | 55 | ## 许可证 / License 56 | 57 | MIT License — 详见 `LICENSE`。 -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "__MSG_appName__", 4 | "short_name": "__MSG_appShortName__", 5 | "version": "1.4.27", 6 | "description": "__MSG_appDesc__", 7 | "default_locale": "en", 8 | 9 | "permissions": [ 10 | "bookmarks", 11 | "storage", 12 | "downloads", 13 | "activeTab", 14 | "tabs", 15 | "alarms", 16 | "contextMenus", 17 | "notifications", 18 | "search" 19 | ], 20 | "host_permissions": [ 21 | "https://*.bing.com/*", 22 | "https://geocoding-api.open-meteo.com/*", 23 | "https://api.open-meteo.com/*", 24 | "https://60s.viki.moe/*", 25 | "https://60api.09cdn.xyz/*", 26 | "https://60s.zeabur.app/*", 27 | "https://60s.crystelf.top/*", 28 | "https://cqxx.site/*", 29 | "https://api.yanyua.icu/*", 30 | "https://60s.tmini.net/*", 31 | "https://60s.7se.cn/*", 32 | "https://api.github.com/*", 33 | "https://raw.githubusercontent.com/*", 34 | "https://dav.jianguoyun.com/*", 35 | "https://dns.google/*", 36 | "https://cloudflare-dns.com/*", 37 | "https://dns.alidns.com/*" 38 | ], 39 | "optional_host_permissions": [ 40 | "", 41 | "http://localhost:*/*", 42 | "https://api.openai.com/*", 43 | "https://api.deepseek.com/*" 44 | ], 45 | 46 | "action": { 47 | "default_title": "__MSG_actionTitle__", 48 | "default_icon": { 49 | "16": "icons/icon16.png", 50 | "32": "icons/icon32.png", 51 | "48": "icons/icon48.png", 52 | "128": "icons/icon128.png" 53 | } 54 | }, 55 | 56 | "background": { 57 | "service_worker": "src/background/index.js", 58 | "type": "module" 59 | }, 60 | 61 | "commands": { 62 | "open_quick_search": { 63 | "suggested_key": { 64 | "default": "Alt+K", 65 | "mac": "Command+Shift+K" 66 | }, 67 | "description": "Open quick bookmark search" 68 | } 69 | }, 70 | 71 | "options_page": "src/pages/options/index.html", 72 | "chrome_url_overrides": { 73 | "newtab": "src/pages/newtab/index.html" 74 | }, 75 | 76 | "icons": { 77 | "16": "icons/icon16.png", 78 | "32": "icons/icon32.png", 79 | "48": "icons/icon48.png", 80 | "128": "icons/icon128.png" 81 | }, 82 | 83 | "content_security_policy": { 84 | "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:* https://www.bing.com https://geocoding-api.open-meteo.com https://api.open-meteo.com https://60s.viki.moe https://60api.09cdn.xyz https://60s.zeabur.app https://60s.crystelf.top https://cqxx.site https://api.yanyua.icu https://60s.tmini.net https://60s.7se.cn https://api.github.com https://raw.githubusercontent.com https://www.googleapis.com https://dns.google https://cloudflare-dns.com https://dns.alidns.com https://api.openai.com https://api.deepseek.com https://api.anthropic.com https://api.groq.com https://api.perplexity.ai https://openrouter.ai https://api.mistral.ai https://api.cohere.ai https://api.together.xyz https://integrate.api.nvidia.com https://api.fireworks.ai https://api.moonshot.cn https://api.minimax.chat https://open.bigmodel.cn https://dashscope.aliyuncs.com https://ark.cn-beijing.volces.com https://api.siliconflow.cn https://api.siliconflow.ai" 85 | } 86 | } -------------------------------------------------------------------------------- /src/pages/newtab/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TidyMark 导航 7 | 8 | 9 | 10 |
11 |
12 |
13 | 17 | 22 |
23 | 24 |
25 |
26 |
27 |
--:--
28 |
29 | 愿你高效、专注地浏览每一天 30 | 33 |
34 |
35 |
36 | 37 |
38 | 45 | 46 | 47 | 61 | 62 | 63 |
64 | 65 |
66 | 69 |
70 | 71 | 72 |
73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /scripts/sync_shared.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | const root = process.cwd(); 6 | const variantRoot = path.join(root, 'extensions', 'organize'); 7 | 8 | const sources = [ 9 | { from: path.join(root, 'services'), to: path.join(variantRoot, 'services') }, 10 | { from: path.join(root, 'icons'), to: path.join(variantRoot, 'icons') }, 11 | { from: path.join(root, 'src', 'background'), to: path.join(variantRoot, 'src', 'background') }, 12 | { from: path.join(root, '_locales'), to: path.join(variantRoot, '_locales') }, 13 | // 纯书签版不包含快速搜索页面,不同步 search 目录 14 | ]; 15 | 16 | const optionsFiles = [ 17 | path.join(root, 'src', 'pages', 'options', 'index.html'), 18 | path.join(root, 'src', 'pages', 'options', 'index.css'), 19 | path.join(root, 'src', 'pages', 'options', 'index.js'), 20 | ]; 21 | 22 | async function ensureDir(dir) { 23 | await fs.promises.mkdir(dir, { recursive: true }); 24 | } 25 | 26 | async function copyRecursive(src, dest) { 27 | await ensureDir(dest); 28 | await fs.promises.cp(src, dest, { recursive: true }); 29 | } 30 | 31 | async function main() { 32 | console.log('Syncing shared code into variant extension...'); 33 | // Copy directories 34 | for (const { from, to } of sources) { 35 | console.log(`Copy ${path.relative(root, from)} -> ${path.relative(root, to)}`); 36 | await copyRecursive(from, to); 37 | } 38 | // Copy options files 39 | const optionsDestDir = path.join(variantRoot, 'src', 'pages', 'options'); 40 | await ensureDir(optionsDestDir); 41 | for (const file of optionsFiles) { 42 | const dest = path.join(optionsDestDir, path.basename(file)); 43 | console.log(`Copy ${path.relative(root, file)} -> ${path.relative(root, dest)}`); 44 | await fs.promises.copyFile(file, dest); 45 | } 46 | // Ensure overlay exists 47 | const overlayPath = path.join(optionsDestDir, 'overlay.js'); 48 | const overlayContents = `(() => {\n function removeNavElements() {\n try {\n const navTabBtn = document.querySelector('.tabs .tab-btn[data-tab=\"nav\"]');\n if (navTabBtn) navTabBtn.remove();\n const navSection = document.getElementById('nav');\n if (navSection) navSection.remove();\n const activeBtn = document.querySelector('.tabs .tab-btn.active');\n if (!activeBtn) {\n const organizeBtn = document.querySelector('.tabs .tab-btn[data-tab=\"organize\"]');\n if (organizeBtn) organizeBtn.classList.add('active');\n }\n const activeContent = document.querySelector('.tab-content.active');\n if (!activeContent) {\n const organizeContent = document.getElementById('organize');\n if (organizeContent) organizeContent.classList.add('active');\n }\n } catch (e) {}\n }\n function patchOptionsManager() {\n try {\n if (typeof optionsManager !== 'undefined' && optionsManager) {\n optionsManager.updateWidgetConfig = function() {};\n }\n } catch (e) {}\n }\n document.addEventListener('DOMContentLoaded', () => {\n removeNavElements();\n patchOptionsManager();\n });\n})();\n`; 49 | await fs.promises.writeFile(overlayPath, overlayContents, 'utf8'); 50 | console.log('Wrote overlay.js to hide Navigation tab.'); 51 | 52 | // Ensure overlay.js script tag is present in options HTML 53 | const optionsHtmlPath = path.join(optionsDestDir, 'index.html'); 54 | let html = await fs.promises.readFile(optionsHtmlPath, 'utf8'); 55 | if (!html.includes('overlay.js')) { 56 | html = html.replace('\n', '\n \n'); 57 | await fs.promises.writeFile(optionsHtmlPath, html, 'utf8'); 58 | console.log('Injected overlay.js into options HTML.'); 59 | } else { 60 | console.log('overlay.js already referenced in options HTML.'); 61 | } 62 | 63 | console.log('Done.'); 64 | } 65 | 66 | main().catch(err => { 67 | console.error('Sync failed:', err); 68 | process.exit(1); 69 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [English] | [中文](./README.zh-CN.md) 3 | 4 | # TidyMark — Smart Bookmark Organizer Extension 5 | 6 | A lightweight Chrome/Edge extension that supports automatic categorization, AI-assisted organizing, dead bookmark detection, and New Tab navigation. Built natively with Manifest V3. 7 | 8 | ## Features 9 | 10 | - Auto categorization: organize bookmarks into categories by rules with one click. 11 | - AI assistance: supports OpenAI/DeepSeek (compatible endpoints) and custom providers to improve classification. 12 | - Dead bookmarks: scan unreachable links; bulk delete or move; optionally limit scanning to a specific folder; ignore internal/local addresses (127.0.0.1, localhost, 10.x, 192.168.x, 172.16–31.x); supports DNS detection (DoH). 13 | - New Tab navigation: show categorized navigation and common information on the New Tab page. 14 | - Bookmark cloud backup / export: daily GitHub backup (configurable in Options), plus manual local backup export; supports WebDAV and Google Drive sync/backup. 15 | - Auto-archive old bookmarks: based on “last visited time”, move less-used bookmarks to the “Archive” folder (threshold configurable, default 180 days; fallback to added time when no visit record). 16 | - Visit frequency stats / usage heat analysis: record bookmark visits and recent activity on the New Tab; supports a “Top” section and basic usage insights. 17 | - Context menu integration: right-click “Add to TidyMark and categorize”; automatically create and move to the matched category folder. 18 | - Shortcut quick search (Full version): open the Quick Search page with a keyboard shortcut; default Alt+K (macOS: Command+Shift+K); toggle available under the "Help" tab in Options. 19 | 20 | ## Installation & Variants 21 | 22 | > Note: After installation, when you first open a New Tab, the browser may prompt to keep the extension’s New Tab override (Chrome/Edge). If you don’t need the New Tab page, choose “Decline / Restore default”. This does not affect core features like bookmark organizing, classification, and backup; your browser’s default New Tab remains. 23 | 24 | ### Variants 25 | 26 | - Organize-only (default recommendation): does not override New Tab; no navigation UI; retains bookmark organizing, classification, dead-links, backup, and GitHub sync. Download: `tidymark-organize-.zip` from GitHub Releases, or load `extensions/organize/` in Developer Mode. 27 | - Stores (Organize-only): 28 | - Chrome Web Store: https://chromewebstore.google.com/detail/pbpfkmnamjpcomlcbdjhbgcpijfafiai?utm_source=item-share-cb 29 | - Microsoft Edge Add-ons: https://microsoftedge.microsoft.com/addons/detail/dhpcgmaljdomhglcfjijpnhmaeppppfa 30 | - Full (with navigation): includes a New Tab navigation and settings; other features are the same. Available via stores or GitHub Releases. 31 | - Stores (Full): 32 | - Chrome Web Store: https://chromewebstore.google.com/detail/tidymark/kfjmkmodmoabhcmgeojbnjbipgiknfkb?utm_source=item-share-cb 33 | - Microsoft Edge Add-ons: https://microsoftedge.microsoft.com/addons/detail/tidymark/ndfhjpodnchjkgpheaompahphpknmpjp 34 | 35 | - GitHub Releases (both variants): https://github.com/PanHywel/TidyMark/releases 36 | 37 | ### Developer Mode 38 | 39 | - Download and unzip the package from GitHub Releases. 40 | - Open `chrome://extensions/` or `edge://extensions/`. 41 | - Enable “Developer mode”, click “Load unpacked”, and select the unzipped `tidymark-organize-...` (organize-only) or `tidymark-full-...` (full) folder. 42 | 43 | ## Screenshots 44 | 45 | New Tab (EN) 46 | Navigation Settings (EN) 47 | Classification Rules (EN) 48 | Deadlinks (EN) 49 | AI Settings (EN) 50 | 51 | — Core info only. For more details, please refer to the source code and comments. 52 | 53 | ## License 54 | 55 | MIT License — see `LICENSE`. -------------------------------------------------------------------------------- /src/pages/search/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TidyMark · 书签搜索 7 | 43 | 44 | 45 |
46 |
47 |

TidyMark · 书签搜索

48 | 49 |

支持自动聚焦、键盘上下选择与回车打开。

50 |
    51 | 52 | 53 |

    全部书签

    54 |
      55 | 56 |
      57 |
      58 | 59 | 60 | -------------------------------------------------------------------------------- /src/pages/preview/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TidyMark 预览 7 | 8 | 24 | 25 | 26 |
      27 |
      28 | 29 |
      30 |
      31 | 32 | 33 | 34 | 35 | 36 | 使用插件前请先手动导出备份 37 | 43 |
      44 |
      45 | 46 | 47 |
      48 | 52 | 58 |
      59 | 60 | 61 |
      62 |
      63 |
      71
      64 |
      总书签
      65 |
      66 |
      67 |
      0
      68 |
      已整理
      69 |
      70 |
      71 |
      71
      72 |
      待分类
      73 |
      74 |
      75 | 76 | 77 |
      78 | 86 | 93 |
      94 | 95 | 96 | 105 | 106 | 107 |
      108 |
      109 |

      待分类书签

      110 | 5 111 |
      112 |
      113 |
      114 | favicon 115 |
      116 |
      Google
      117 |
      https://www.google.com
      118 |
      119 |
      120 |
      121 | favicon 122 |
      123 |
      GitHub
      124 |
      https://github.com
      125 |
      126 |
      127 |
      128 | favicon 129 |
      130 |
      Stack Overflow
      131 |
      https://stackoverflow.com
      132 |
      133 |
      134 |
      135 |
      136 |
      137 | 138 | 139 | 140 | 141 | 142 | 这些是还未分类的书签,点击"自动整理"让AI帮你分类,或手动创建分类进行整理 143 |
      144 |
      145 |
      146 | 147 | 148 |
      149 |
      150 |

      分类管理

      151 | 157 |
      158 |
      159 | 160 |
      161 |
      162 |
      163 | 164 | 165 | 166 |
      167 |

      还没有创建分类

      168 |

      点击上方的 + 按钮创建第一个分类,或使用"自动整理"功能让AI帮你分类书签

      169 |
      170 |
      171 |
      172 |
      173 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /scripts/capture_screenshots.mjs: -------------------------------------------------------------------------------- 1 | import { chromium } from 'playwright'; 2 | import fs from 'fs/promises'; 3 | 4 | const DEFAULT_LANGS = ['zh-CN', 'zh-TW', 'en', 'ru']; 5 | 6 | function arg(name, def) { 7 | const hit = process.argv.find(a => a.startsWith(`--${name}=`)); 8 | return hit ? hit.split('=')[1] : def; 9 | } 10 | 11 | const BASE = arg('base', 'http://localhost:8000'); 12 | const WIDTH = parseInt(arg('width', '1280'), 10); 13 | const HEIGHT = parseInt(arg('height', '800'), 10); 14 | const OUT = arg('out', 'assets/screenshots'); 15 | const LANGS = (arg('langs', DEFAULT_LANGS.join(','))).split(',').map(s => s.trim()).filter(Boolean); 16 | const PREWAIT_MS = parseInt(arg('prewait', '5000'), 10); // 新标签页截图前的预等待毫秒数 17 | const HIDE_SIXTY = String(arg('hideSixty', 'true')).toLowerCase() === 'true'; // 是否隐藏 60s 栏目 18 | const VARIANT = arg('variant', 'full'); // full | organize 19 | const ORGANIZE_BASE = arg('organizeBase', BASE); 20 | 21 | async function ensureLanguage(page, lang) { 22 | await page.waitForLoadState('domcontentloaded'); 23 | await page.evaluate(`(async () => { if (window.I18n) { try { await window.I18n.init(); } catch(e){} } })()`); 24 | await page.evaluate(`(async () => { if (window.I18n && window.I18n.setLanguage) { await window.I18n.setLanguage('${lang}'); } })()`); 25 | await page.waitForTimeout(300); 26 | } 27 | 28 | async function waitForWallpaperOrDisabled(page, timeout = 20000) { 29 | // 等待: 30 | // 1) 壁纸被禁用(无 has-wallpaper),或 31 | // 2) body 背景图 url 可解析且图片已成功加载 32 | await page.waitForFunction(() => { 33 | const body = document && document.body; 34 | if (!body) return false; 35 | const has = body.classList.contains('has-wallpaper'); 36 | const bg = getComputedStyle(body).backgroundImage; 37 | // 壁纸关闭,则无需等待 38 | if (!has) return true; 39 | if (!bg || bg === 'none') return false; 40 | const m = bg.match(/url\((['"]?)(.*?)\1\)/); 41 | const url = m && m[2]; 42 | if (!url) return false; 43 | const img = (window.__bgImgReady ||= new Image()); 44 | if (img.src !== url) img.src = url; 45 | return img.complete && img.naturalWidth > 0; 46 | }, { timeout }); 47 | } 48 | 49 | async function waitForVisibleImages(page, timeout = 15000) { 50 | // 等待页面上“可见”的 元素加载完成(如 60s 封面) 51 | await page.waitForFunction(() => { 52 | const imgs = Array.from(document.querySelectorAll('img')) 53 | .filter(img => !!(img.offsetWidth || img.offsetHeight || img.getClientRects().length)); 54 | if (imgs.length === 0) return true; 55 | return imgs.every(img => img.complete && img.naturalWidth > 0); 56 | }, { timeout }); 57 | } 58 | 59 | async function screenshotNewtab(context, base, outDir, width, height, lang) { 60 | const page = await context.newPage(); 61 | // 转发页面 console 到 Node 侧,便于观察等待过程 62 | page.on('console', (msg) => { 63 | const type = msg.type(); 64 | if (['log','debug','warning','error'].includes(type)) { 65 | console.log(`[page:${lang}] ${type}:`, msg.text()); 66 | } 67 | }); 68 | 69 | console.log(`[shots] [newtab] lang=${lang} -> ${base}/src/pages/newtab/index.html`); 70 | await page.setViewportSize({ width, height }); 71 | await page.goto(`${base}/src/pages/newtab/index.html`, { waitUntil: 'domcontentloaded' }); 72 | await ensureLanguage(page, lang); 73 | console.log(`[shots] [newtab] hideSixty=${HIDE_SIXTY}`); 74 | if (HIDE_SIXTY) { 75 | // 双保险:即使偏好未及时被读取,也直接隐藏元素 76 | await page.evaluate(() => { try { const x = document.getElementById('sixty-seconds'); if (x) x.hidden = true; } catch {} }); 77 | } 78 | console.log(`[shots] [newtab] prewait ${PREWAIT_MS}ms before loading checks`); 79 | await page.waitForTimeout(PREWAIT_MS); 80 | // 记录当前语言与壁纸初始状态 81 | const activeLang = await page.evaluate(() => { 82 | try { 83 | if (window.I18n) { 84 | if (typeof window.I18n.getLanguageSync === 'function') return window.I18n.getLanguageSync(); 85 | if (typeof window.I18n.getLanguage === 'function') return window.I18n.getLanguage(); 86 | } 87 | } catch {} 88 | return null; 89 | }); 90 | console.log(`[shots] [newtab] activeLang=`, activeLang); 91 | const startPrefs = await page.evaluate(() => { 92 | const body = document && document.body; 93 | const bg = body ? getComputedStyle(body).backgroundImage : ''; 94 | const has = body ? body.classList.contains('has-wallpaper') : false; 95 | let ls = null; 96 | try { ls = localStorage.getItem('wallpaperEnabled'); } catch {} 97 | console.debug('[wait] init has-wallpaper=', has, 'bg=', bg, 'ls=', ls); 98 | return { wallpaperEnabledLocalStorage: ls, hasWallpaperClass: has, backgroundImage: bg }; 99 | }); 100 | console.log(`[shots] [newtab] before-wait prefs=`, startPrefs); 101 | // 等待壁纸与可见图片加载完毕(或确认壁纸关闭) 102 | await waitForWallpaperOrDisabled(page, 22000); 103 | const afterWallpaper = await page.evaluate(() => { 104 | const body = document && document.body; 105 | const has = body ? body.classList.contains('has-wallpaper') : false; 106 | const bg = body ? getComputedStyle(body).backgroundImage : ''; 107 | const m = bg && bg !== 'none' ? bg.match(/url\((['"]?)(.*?)\1\)/) : null; 108 | const url = m && m[2]; 109 | let complete = false, naturalWidth = 0; 110 | if (url) { 111 | const img = new Image(); 112 | img.src = url; 113 | complete = img.complete; 114 | naturalWidth = img.naturalWidth || 0; 115 | } 116 | console.debug('[wait] after wallpaper has=', has, 'bg=', bg, 'url=', url, 'complete=', complete, 'nw=', naturalWidth); 117 | return { has, bg, url, complete, naturalWidth }; 118 | }); 119 | console.log(`[shots] [newtab] after-wait wallpaper=`, afterWallpaper); 120 | await waitForVisibleImages(page, 12000); 121 | const visibleImgs = await page.evaluate(() => { 122 | const vis = Array.from(document.querySelectorAll('img')) 123 | .filter(img => !!(img.offsetWidth || img.offsetHeight || img.getClientRects().length)) 124 | .map(img => ({ src: img.src, complete: img.complete, nw: img.naturalWidth || 0, display: getComputedStyle(img).display })); 125 | console.debug('[wait-imgs] visible imgs=', vis); 126 | return vis; 127 | }); 128 | console.log(`[shots] [newtab] visible-imgs=`, visibleImgs); 129 | await page.waitForTimeout(120); 130 | await fs.mkdir(`${outDir}/${lang}`, { recursive: true }); 131 | await page.screenshot({ path: `${outDir}/${lang}/newtab_${lang}.png`, fullPage: false }); 132 | await page.close(); 133 | } 134 | 135 | async function screenshotOptionsTab(context, base, outDir, width, height, lang, tab, filename) { 136 | const page = await context.newPage(); 137 | await page.setViewportSize({ width, height }); 138 | await page.goto(`${base}/src/pages/options/index.html`, { waitUntil: 'domcontentloaded' }); 139 | await ensureLanguage(page, lang); 140 | await page.click(`.tab-btn[data-tab="${tab}"]`); 141 | await page.waitForSelector(`section.tab-content#${tab}`); 142 | await page.waitForTimeout(400); 143 | await fs.mkdir(`${outDir}/${lang}`, { recursive: true }); 144 | await page.screenshot({ path: `${outDir}/${lang}/${filename}_${lang}.png`, fullPage: false }); 145 | await page.close(); 146 | } 147 | 148 | async function screenshotOrganizeOptionsTab(context, base, outDir, width, height, lang, tab, filename) { 149 | const page = await context.newPage(); 150 | await page.setViewportSize({ width, height }); 151 | await page.goto(`${base}/extensions/organize/src/pages/options/index.html`, { waitUntil: 'domcontentloaded' }); 152 | await ensureLanguage(page, lang); 153 | await page.click(`.tab-btn[data-tab="${tab}"]`); 154 | await page.waitForSelector(`section.tab-content#${tab}`); 155 | await page.waitForTimeout(400); 156 | await fs.mkdir(`${outDir}/${lang}`, { recursive: true }); 157 | await page.screenshot({ path: `${outDir}/${lang}/organize_${filename}_${lang}.png`, fullPage: false }); 158 | await page.close(); 159 | } 160 | 161 | (async () => { 162 | const browser = await chromium.launch(); 163 | const context = await browser.newContext(); 164 | // 预置偏好:确保壁纸开启,且支持隐藏 60s(预览环境无 chrome.storage.sync,使用 localStorage) 165 | await context.addInitScript((cfg) => { 166 | try { 167 | if (cfg && cfg.wallpaperEnabled) localStorage.setItem('wallpaperEnabled', 'true'); 168 | if (cfg && cfg.hideSixty) localStorage.setItem('sixtySecondsEnabled', 'false'); 169 | } catch {} 170 | }, { wallpaperEnabled: true, hideSixty: HIDE_SIXTY }); 171 | 172 | for (const lang of LANGS) { 173 | if (VARIANT === 'organize') { 174 | // 纯书签整理版:不拍新标签页,只拍选项页的核心功能标签 175 | await screenshotOrganizeOptionsTab(context, ORGANIZE_BASE, OUT, WIDTH, HEIGHT, lang, 'rules', 'classification'); 176 | await screenshotOrganizeOptionsTab(context, ORGANIZE_BASE, OUT, WIDTH, HEIGHT, lang, 'deadlinks', 'deadlinks'); 177 | await screenshotOrganizeOptionsTab(context, ORGANIZE_BASE, OUT, WIDTH, HEIGHT, lang, 'ai', 'ai'); 178 | } else { 179 | await screenshotNewtab(context, BASE, OUT, WIDTH, HEIGHT, lang); 180 | await screenshotOptionsTab(context, BASE, OUT, WIDTH, HEIGHT, lang, 'rules', 'classification'); 181 | await screenshotOptionsTab(context, BASE, OUT, WIDTH, HEIGHT, lang, 'deadlinks', 'deadlinks'); 182 | await screenshotOptionsTab(context, BASE, OUT, WIDTH, HEIGHT, lang, 'nav', 'navigation'); 183 | await screenshotOptionsTab(context, BASE, OUT, WIDTH, HEIGHT, lang, 'ai', 'ai'); 184 | } 185 | } 186 | 187 | await context.close(); 188 | await browser.close(); 189 | })(); -------------------------------------------------------------------------------- /src/pages/search/index.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const input = document.getElementById('searchBox'); 3 | const resultsEl = document.getElementById('results'); 4 | const stateEl = document.getElementById('state'); 5 | const allResultsEl = document.getElementById('allResults'); 6 | const allStateEl = document.getElementById('allState'); 7 | const allTitleEl = document.getElementById('allTitle'); 8 | let items = []; 9 | let activeIndex = -1; 10 | let allItems = []; 11 | let composing = false; 12 | 13 | const focusInput = () => { 14 | if (!input) return; 15 | try { 16 | requestAnimationFrame(() => setTimeout(() => input.focus({ preventScroll: true }), 0)); 17 | } catch (_) { 18 | try { input.focus(); } catch (_) {} 19 | } 20 | }; 21 | 22 | // 初次加载与标签聚焦时都尝试聚焦输入框 23 | window.addEventListener('load', focusInput, { once: true }); 24 | window.addEventListener('focus', focusInput); 25 | document.addEventListener('visibilitychange', () => { 26 | if (document.visibilityState === 'visible') focusInput(); 27 | }); 28 | 29 | // 接收后台广播消息,进行聚焦 30 | try { 31 | chrome?.runtime?.onMessage?.addListener((msg) => { 32 | if (msg && msg.type === 'focusSearchInput') { 33 | if (input) input.value = ''; 34 | focusInput(); 35 | // 复用时清空结果并展示“全部书签”区块 36 | clearResults(); 37 | showAllSection(true); 38 | if (allItems && allItems.length > 0) { 39 | renderAllBookmarks(allItems); 40 | } else { 41 | loadAllBookmarks(); 42 | } 43 | } 44 | }); 45 | } catch (_) {} 46 | 47 | const debounce = (fn, ms = 200) => { 48 | let t = null; 49 | return (...args) => { 50 | if (t) clearTimeout(t); 51 | t = setTimeout(() => fn(...args), ms); 52 | }; 53 | }; 54 | 55 | const setState = (text, hidden = false) => { 56 | if (!stateEl) return; 57 | stateEl.textContent = text || ''; 58 | stateEl.hidden = hidden; 59 | }; 60 | 61 | const setAllState = (text, hidden = false) => { 62 | if (!allStateEl) return; 63 | allStateEl.textContent = text || ''; 64 | allStateEl.hidden = hidden; 65 | }; 66 | 67 | const clearResults = () => { 68 | items = []; 69 | activeIndex = -1; 70 | if (resultsEl) resultsEl.innerHTML = ''; 71 | setState('', true); 72 | }; 73 | 74 | const showAllSection = (show) => { 75 | if (allTitleEl) allTitleEl.hidden = !show; 76 | if (allResultsEl) allResultsEl.hidden = !show; 77 | if (!show) setAllState('', true); 78 | }; 79 | 80 | const highlight = (text, q) => { 81 | if (!q) return escapeHtml(text); 82 | const escQ = escapeRegExp(q); 83 | const re = new RegExp(`(${escQ})`, 'ig'); 84 | return escapeHtml(text).replace(re, '$1'); 85 | }; 86 | 87 | const escapeHtml = (str = '') => str 88 | .replace(/&/g, '&') 89 | .replace(//g, '>') 91 | .replace(/"/g, '"') 92 | .replace(/'/g, '''); 93 | 94 | const escapeRegExp = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 95 | 96 | const faviconFor = (url) => { 97 | try { 98 | const u = new URL(url); 99 | return `${u.protocol}//${u.hostname}/favicon.ico`; 100 | } catch (_) { 101 | return ''; 102 | } 103 | }; 104 | 105 | const defaultIconUrl = () => { 106 | try { 107 | return chrome.runtime.getURL('icons/icon16.svg'); 108 | } catch (_) { 109 | return '/icons/icon16.svg'; 110 | } 111 | }; 112 | 113 | const renderAllBookmarks = (list) => { 114 | if (!allResultsEl) return; 115 | allResultsEl.innerHTML = ''; 116 | if (!list || list.length === 0) { 117 | setAllState('没有书签', false); 118 | return; 119 | } 120 | setAllState('', true); 121 | const frag = document.createDocumentFragment(); 122 | list.forEach((bk) => { 123 | const li = document.createElement('li'); 124 | li.className = 'item'; 125 | li.innerHTML = ` 126 | 127 |
      128 |
      129 |
      ${escapeHtml(bk.title || bk.url || '')}
      130 |
      131 |
      ${escapeHtml(bk.url || '')}
      132 |
      133 | `; 134 | const img = li.querySelector('.favicon'); 135 | const iconSrc = faviconFor(bk.url); 136 | img.src = iconSrc || defaultIconUrl(); 137 | img.addEventListener('error', () => { img.src = defaultIconUrl(); }); 138 | li.addEventListener('click', () => openBookmark(bk.url)); 139 | frag.appendChild(li); 140 | }); 141 | allResultsEl.appendChild(frag); 142 | }; 143 | 144 | const renderResults = (list, q) => { 145 | if (!resultsEl) return; 146 | resultsEl.innerHTML = ''; 147 | if (!list || list.length === 0) { 148 | setState('无匹配结果', false); 149 | return; 150 | } 151 | setState('', true); 152 | const frag = document.createDocumentFragment(); 153 | list.forEach((bk, idx) => { 154 | const li = document.createElement('li'); 155 | li.className = 'item'; 156 | li.tabIndex = -1; 157 | li.dataset.index = String(idx); 158 | li.innerHTML = ` 159 | 160 |
      161 |
      162 |
      ${highlight(bk.title || bk.url || '', q)}
      163 |
      164 |
      ${highlight(bk.url || '', q)}
      165 |
      166 | `; 167 | const img = li.querySelector('.favicon'); 168 | const iconSrc = faviconFor(bk.url); 169 | img.src = iconSrc || defaultIconUrl(); 170 | img.addEventListener('error', () => { img.src = defaultIconUrl(); }); 171 | li.addEventListener('click', () => openBookmark(bk.url)); 172 | frag.appendChild(li); 173 | }); 174 | resultsEl.appendChild(frag); 175 | // 默认选中第一项 176 | activeIndex = list.length > 0 ? 0 : -1; 177 | updateActive(); 178 | }; 179 | 180 | const updateActive = () => { 181 | if (!resultsEl) return; 182 | resultsEl.querySelectorAll('.item').forEach(el => el.classList.remove('active')); 183 | if (activeIndex >= 0) { 184 | const el = resultsEl.querySelector(`.item[data-index="${activeIndex}"]`); 185 | if (el) el.classList.add('active'); 186 | } 187 | }; 188 | 189 | const moveActive = (delta) => { 190 | if (!items || items.length === 0) return; 191 | activeIndex = Math.max(0, Math.min(items.length - 1, activeIndex + delta)); 192 | updateActive(); 193 | }; 194 | 195 | const openBookmark = (url) => { 196 | if (!url) return; 197 | try { 198 | window.open(url, '_blank', 'noopener'); 199 | } catch (_) {} 200 | }; 201 | 202 | const doSearch = async (q) => { 203 | const query = (q || '').trim(); 204 | if (query.length === 0) { 205 | clearResults(); 206 | // 显示全部书签区块 207 | showAllSection(true); 208 | // 若还未加载,则触发加载;否则直接渲染 209 | if (allItems && allItems.length > 0) { 210 | renderAllBookmarks(allItems); 211 | } else { 212 | loadAllBookmarks(); 213 | } 214 | return; 215 | } 216 | // 输入有查询时隐藏“全部书签”区块 217 | showAllSection(false); 218 | setState('正在搜索…', false); 219 | try { 220 | const res = await chrome.runtime.sendMessage({ action: 'searchBookmarks', query }); 221 | if (!res || !res.success) throw new Error(res && res.error || '搜索失败'); 222 | items = Array.isArray(res.data) ? res.data : []; 223 | renderResults(items, query); 224 | } catch (e) { 225 | setState('搜索失败,请稍后重试', false); 226 | console.warn(e); 227 | } 228 | }; 229 | 230 | const debouncedSearch = debounce(doSearch, 220); 231 | 232 | input?.addEventListener('input', (e) => { 233 | debouncedSearch(String(e.target.value || '')); 234 | }); 235 | 236 | // 处理输入法组合状态,避免候选阶段的回车或方向键触发行为 237 | input?.addEventListener('compositionstart', () => { composing = true; }); 238 | input?.addEventListener('compositionend', () => { composing = false; }); 239 | 240 | input?.addEventListener('keydown', (e) => { 241 | // 输入法候选阶段,不拦截键盘事件(包含 Enter/方向键),避免误触 242 | if (e.isComposing || composing || e.keyCode === 229) { 243 | return; 244 | } 245 | if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); } 246 | else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); } 247 | else if (e.key === 'Enter') { 248 | e.preventDefault(); 249 | if (activeIndex >= 0 && items[activeIndex]) openBookmark(items[activeIndex].url); 250 | } 251 | }); 252 | 253 | // 扁平化书签树(仅保留包含 url 的节点) 254 | const flattenBookmarks = (tree) => { 255 | const out = []; 256 | const walk = (nodes) => { 257 | for (const n of nodes) { 258 | if (n.url) out.push(n); 259 | if (n.children) walk(n.children); 260 | } 261 | }; 262 | walk(Array.isArray(tree) ? tree : [tree]); 263 | return out; 264 | }; 265 | 266 | const loadAllBookmarks = async () => { 267 | setAllState('正在加载全部书签…', false); 268 | try { 269 | const res = await chrome.runtime.sendMessage({ action: 'getBookmarks' }); 270 | if (!res || !res.success) throw new Error(res && res.error || '加载书签失败'); 271 | const tree = Array.isArray(res.data) ? res.data : []; 272 | allItems = flattenBookmarks(tree).filter(b => b && b.url); 273 | renderAllBookmarks(allItems); 274 | } catch (e) { 275 | setAllState('加载失败,请稍后重试', false); 276 | console.warn(e); 277 | } 278 | }; 279 | 280 | // 初次进入显示全部书签 281 | showAllSection(true); 282 | loadAllBookmarks(); 283 | })(); -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # TidyMark Browser Extension · Privacy Policy 2 | 3 | Effective date: 2025-10-31 4 | 5 | This privacy policy explains how the TidyMark browser extension (“TidyMark”, “we”) handles data. TidyMark is a client-side extension; we do not operate any backend server to collect or store your personal data. 6 | 7 | ## What We Process 8 | - Bookmarks management: Reads and updates your browser bookmarks to organize them. Data is stored locally in your browser storage; nothing is sent to us. 9 | - Configuration and rules: Your sorting rules, tags, and preferences are stored locally. Optional cloud sync can back up these to your own accounts (WebDAV, GitHub, Google Drive) if you enable it. 10 | - New Tab modules (optional): 11 | - Bing daily wallpaper: Fetches image metadata and resources from `https://www.bing.com`. No identifiers are added by us. 12 | - 60s summaries: Fetches daily summaries from community instances you choose. No identifiers are added by us. 13 | - Weather: Calls Open‑Meteo APIs to get weather based on a city name or similar query you provide. We do not request browser geolocation permission. 14 | - Dead-link scan (optional): Performs network requests to bookmarked URLs to check status codes. We do not collect page contents; only status results are kept locally. 15 | - DNS over HTTPS (optional): Sends DNS queries to providers you select (e.g., Google/Cloudflare/AliDNS) for resolution. These providers receive your queried domain names. 16 | - AI integrations (optional): If you configure an AI provider (local at `http://localhost` or online like OpenAI/DeepSeek), prompts may include bookmark titles/metadata you choose to process. Keys are stored locally and sent only to the configured provider. 17 | 18 | ## What We Do Not Do 19 | - No analytics or telemetry by default. 20 | - No selling or sharing of your data with third parties. 21 | - No reading of browsing history beyond your bookmarks unless you enable dead‑link scanning. 22 | 23 | ## Storage and Security 24 | - Local storage: All configuration, cache, and tokens are stored in your browser storage. We do not transmit them to any server we control. 25 | - Cloud sync (optional): When enabled, data is written to your own WebDAV server, GitHub repository, or Google Drive. Your credentials/tokens remain on your device. 26 | - Encryption: We rely on the security of the platforms/APIs you choose; we do not run our own servers. Please protect your device and accounts. 27 | 28 | ## Third-Party Services (Optional) 29 | - Bing (`https://www.bing.com`): Wallpapers and metadata. 30 | - Open‑Meteo (`https://api.open-meteo.com`, `https://geocoding-api.open-meteo.com`): Weather and geocoding. 31 | - 60s community instances: Daily summaries from instances you select. 32 | - DNS over HTTPS providers (`dns.google`, `cloudflare-dns.com`, `dns.alidns.com`): DNS resolution. 33 | - GitHub (`https://api.github.com`, `https://raw.githubusercontent.com`): Config sync via your repository. 34 | - Google Drive (`https://www.googleapis.com`): Config backup/sync using your account. 35 | - AI providers (as configured): Local `http://localhost` or online endpoints you add. Provider terms apply. 36 | 37 | ## Your Controls 38 | - Feature opt‑in/out: All online modules (weather, wallpapers, AI, DNS, cloud sync) are optional and can be disabled. 39 | - Reset & deletion: Use “Reset” or “Options” pages to remove local data/configs. For cloud sync, remove files/tokens in your own accounts. 40 | - Keys & endpoints: You fully control AI provider endpoints/models and can clear tokens at any time. 41 | 42 | ## Children’s Privacy 43 | TidyMark is not directed to children under 13. We do not knowingly collect personal information from children. 44 | 45 | ## Changes to This Policy 46 | We may update this policy to reflect product changes. We will revise the “Effective date” above accordingly. 47 | 48 | ## Contact 49 | For privacy questions or requests, please open an issue at: https://github.com/PanHywel/TidyMark/issues 50 | 51 | --- 52 | 53 | # TidyMark 浏览器扩展 · 隐私政策(简体中文) 54 | 55 | 生效日期:2025-10-31 56 | 57 | 本隐私政策说明 TidyMark 浏览器扩展(以下简称 “TidyMark”、“我们”)如何处理数据。TidyMark 是纯客户端扩展,我们不运营任何收集或存储您个人数据的后台服务器。 58 | 59 | ## 我们处理的数据 60 | - 书签管理:读取并更新浏览器书签以进行整理。数据存储在本地浏览器中;不会发送给我们。 61 | - 配置与规则:您的排序规则、标签与偏好保存在本地。若您开启云备份/同步(WebDAV、GitHub、Google Drive),相应数据将同步到您自己的账户。 62 | - 新标签页模块(可选): 63 | - 必应每日壁纸:从 `https://www.bing.com` 获取图片元数据与资源。我们不会添加任何标识符。 64 | - 60s 摘要:从您选择的社区实例拉取每日摘要。我们不会添加任何标识符。 65 | - 天气:调用 Open‑Meteo 接口根据您输入的城市名称等获取天气。我们不请求浏览器地理位置权限。 66 | - 失效链接扫描(可选):对书签链接发起网络请求检查状态码。我们不采集页面内容;本地仅保存状态结果。 67 | - DoH 域名解析(可选):向您选择的 DoH 提供方发送域名查询。提供方会收到您查询的域名。 68 | - AI 集成(可选):若您配置 AI 提供方(本地 `http://localhost` 或在线如 OpenAI/DeepSeek),提示词可能包含您选择处理的书签标题/元数据。密钥仅存储于本地并仅发送到您配置的提供方。 69 | 70 | ## 我们不做的事 71 | - 默认不启用任何分析或遥测。 72 | - 不出售或共享您的数据给第三方。 73 | - 除非您启用失效链接扫描,否则我们不会读取书签以外的浏览记录。 74 | 75 | ## 存储与安全 76 | - 本地存储:所有配置、缓存与令牌保存在浏览器本地。我们不会将其传输到任何由我们控制的服务器。 77 | - 云同步(可选):启用后,数据将写入您自己的 WebDAV 服务器、GitHub 仓库或 Google Drive。您的凭据/令牌仅保留在您的设备上。 78 | - 加密:我们依赖您所选择的平台/API 的安全性;我们不运行自有服务器。请妥善保护您的设备与账户。 79 | 80 | ## 第三方服务(可选) 81 | - 必应(`https://www.bing.com`):壁纸与元数据。 82 | - Open‑Meteo(`https://api.open-meteo.com`、`https://geocoding-api.open-meteo.com`):天气与地理编码。 83 | - 60s 社区实例:来自您选择实例的每日摘要。 84 | - DoH 提供方(`dns.google`、`cloudflare-dns.com`、`dns.alidns.com`):域名解析。 85 | - GitHub(`https://api.github.com`、`https://raw.githubusercontent.com`):通过您的仓库进行配置同步。 86 | - Google Drive(`https://www.googleapis.com`):通过您的账户进行配置备份/同步。 87 | - AI 提供方(按您配置):本地 `http://localhost` 或在线端点。各提供方条款适用。 88 | 89 | ## 您的控制 90 | - 功能开关:所有在线模块(天气、壁纸、AI、DoH、云同步)均为可选且可关闭。 91 | - 重置与删除:通过“重置”或“选项”页面清除本地数据/配置。云同步数据请在您的账户中删除文件/令牌。 92 | - 密钥与端点:您完全控制 AI 端点与模型,并可随时清除令牌。 93 | 94 | ## 儿童隐私 95 | TidyMark 不面向 13 岁以下儿童。我们不会明知收集儿童个人信息。 96 | 97 | ## 政策更新 98 | 我们可能根据产品变更更新本政策,并相应调整上述“生效日期”。 99 | 100 | ## 联系方式 101 | 隐私相关问题或请求,请在此提交 Issue:https://github.com/PanHywel/TidyMark/issues 102 | 103 | --- 104 | 105 | # TidyMark 瀏覽器擴充 · 隱私權政策(繁體中文) 106 | 107 | 生效日期:2025-10-31 108 | 109 | 本隱私權政策說明 TidyMark 瀏覽器擴充(以下稱「TidyMark」、「我們」)如何處理資料。TidyMark 為純用戶端擴充,我們不營運任何蒐集或儲存您個人資料的後端伺服器。 110 | 111 | ## 我們處理的資料 112 | - 書籤管理:讀取並更新瀏覽器書籤以進行整理。資料儲存於本地瀏覽器;不會傳送給我們。 113 | - 設定與規則:您的排序規則、標籤與偏好保存在本地。若您啟用雲端備份/同步(WebDAV、GitHub、Google Drive),相應資料將同步至您自己的帳戶。 114 | - 新分頁模組(可選): 115 | - 必應每日桌布:自 `https://www.bing.com` 取得圖片中繼資料與資源。我們不添加任何識別碼。 116 | - 60s 摘要:自您選擇的社群實例抓取每日摘要。我們不添加任何識別碼。 117 | - 天氣:呼叫 Open‑Meteo 介面依您輸入的城市名稱等取得天氣。我們不請求瀏覽器地理位置權限。 118 | - 連結失效掃描(可選):對書籤連結發出網路請求檢查狀態碼。我們不蒐集頁面內容;僅在本地保存狀態結果。 119 | - DoH 網域解析(可選):向您選擇的 DoH 提供者送出網域查詢。提供者會收到您查詢的網域。 120 | - AI 整合(可選):若您設定 AI 提供者(本地 `http://localhost` 或線上如 OpenAI/DeepSeek),提示詞可能包含您選擇處理的書籤標題/中繼資料。金鑰僅儲存於本地並僅傳送至您設定的提供者。 121 | 122 | ## 我們不做的事 123 | - 預設不啟用任何分析或遙測。 124 | - 不出售或共享您的資料給第三方。 125 | - 除非您啟用連結失效掃描,否則我們不讀取書籤以外的瀏覽記錄。 126 | 127 | ## 儲存與安全 128 | - 本地儲存:所有設定、快取與金鑰儲存在瀏覽器本地。我們不傳送至任何由我們控制的伺服器。 129 | - 雲端同步(可選):啟用後,資料將寫入您自己的 WebDAV 伺服器、GitHub 儲存庫或 Google Drive。您的憑證/金鑰僅保留在您的裝置上。 130 | - 加密:我們依賴您所選平台/API 的安全性;我們不運行自有伺服器。請妥善保護您的裝置與帳戶。 131 | 132 | ## 第三方服務(可選) 133 | - 必應(`https://www.bing.com`):桌布與中繼資料。 134 | - Open‑Meteo(`https://api.open-meteo.com`、`https://geocoding-api.open-meteo.com`):天氣與地理編碼。 135 | - 60s 社群實例:來自您選擇實例的每日摘要。 136 | - DoH 提供者(`dns.google`、`cloudflare-dns.com`、`dns.alidns.com`):網域解析。 137 | - GitHub(`https://api.github.com`、`https://raw.githubusercontent.com`):透過您的儲存庫進行設定同步。 138 | - Google Drive(`https://www.googleapis.com`):透過您的帳戶進行設定備份/同步。 139 | - AI 提供者(依您設定):本地 `http://localhost` 或線上端點。各提供者條款適用。 140 | 141 | ## 您的控制 142 | - 功能開關:所有線上模組(天氣、桌布、AI、DoH、雲端同步)皆為可選且可關閉。 143 | - 重設與刪除:透過「重設」或「選項」頁面清除本地資料/設定。雲端同步資料請於您的帳戶中刪除檔案/金鑰。 144 | - 金鑰與端點:您完全控制 AI 端點與模型,並可隨時清除金鑰。 145 | 146 | ## 兒童隱私 147 | TidyMark 不面向 13 歲以下兒童。我們不會明知蒐集兒童個人資訊。 148 | 149 | ## 政策更新 150 | 我們可能依產品變更更新本政策,並相應調整上述「生效日期」。 151 | 152 | ## 聯絡方式 153 | 隱私相關問題或請求,請於此提交 Issue:https://github.com/PanHywel/TidyMark/issues 154 | 155 | --- 156 | 157 | # TidyMark · Политика конфиденциальности (Русский) 158 | 159 | Дата вступления в силу: 2025-10-31 160 | 161 | Эта политика объясняет, как расширение TidyMark обрабатывает данные. TidyMark — клиентское расширение; мы не управляем сервером, который собирает или хранит ваши персональные данные. 162 | 163 | ## Что мы обрабатываем 164 | - Управление закладками: чтение и обновление закладок для их организации. Данные хранятся локально в браузере и не отправляются нам. 165 | - Настройки и правила: ваши правила сортировки, теги и предпочтения хранятся локально. При включении облачной синхронизации (WebDAV, GitHub, Google Drive) данные отправляются в ваши аккаунты. 166 | - Модули новой вкладки (по желанию): 167 | - Ежедневные обои Bing: запросы к `https://www.bing.com` без добавления идентификаторов. 168 | - Ежедневные 60s‑сводки: запросы к выбранным вами инстансам сообщества. 169 | - Погода: обращения к Open‑Meteo на основе введённого вами города. Мы не запрашиваем разрешение геолокации браузера. 170 | - Проверка «битых» ссылок (по желанию): сетевые запросы к URL закладок для проверки статуса. Контент страниц не собирается; сохраняются только локальные результаты. 171 | - DNS по HTTPS (по желанию): запросы к выбранным вами провайдерам DoH. Им передаются имена запрашиваемых доменов. 172 | - Интеграции ИИ (по желанию): если настроен провайдер ИИ (локальный `http://localhost` или онлайн, например OpenAI/DeepSeek), в промпты могут попадать названия/метаданные закладок по вашему выбору. Ключи хранятся локально и отправляются только настроенному провайдеру. 173 | 174 | ## Чего мы не делаем 175 | - Нет аналитики и телеметрии по умолчанию. 176 | - Нет продажи или передачи ваших данных третьим лицам. 177 | - Без чтения истории браузера за пределами закладок, если вы не включили проверку «битых» ссылок. 178 | 179 | ## Хранение и безопасность 180 | - Локальное хранение: настройки, кеш и токены хранятся в локальном хранилище браузера. Мы не передаём их на наши серверы. 181 | - Облачная синхронизация (по желанию): данные пишутся на ваш сервер WebDAV, репозиторий GitHub или Google Drive. Учётные данные/токены остаются на вашем устройстве. 182 | - Шифрование: мы полагаемся на безопасность выбранных вами платформ/API; собственных серверов мы не запускаем. Защитите ваше устройство и аккаунты. 183 | 184 | ## Сторонние сервисы (по желанию) 185 | - Bing (`https://www.bing.com`), Open‑Meteo (`https://api.open-meteo.com`, `https://geocoding-api.open-meteo.com`), DNS‑провайдеры (`dns.google`, `cloudflare-dns.com`, `dns.alidns.com`), GitHub, Google Drive, ИИ‑провайдеры по вашей настройке. 186 | 187 | ## Ваши возможности 188 | - Включение/отключение модулей и функций; очистка данных через страницы “Сброс/Опции”; управление токенами и эндпоинтами. 189 | 190 | ## Дети 191 | Расширение не предназначено для детей младше 13 лет и не собирает их персональные данные. 192 | 193 | ## Изменения 194 | Мы можем обновлять эту политику; дата вступления в силу будет пересмотрена. 195 | 196 | ## Контакты 197 | Вопросы по конфиденциальности: открывайте issue здесь — https://github.com/PanHywel/TidyMark/issues -------------------------------------------------------------------------------- /services/bookmarkService.js: -------------------------------------------------------------------------------- 1 | // bookmarkService.js - 书签管理服务 2 | 3 | class BookmarkService { 4 | constructor() { 5 | this.cache = new Map(); 6 | this.cacheExpiry = 5 * 60 * 1000; // 5分钟缓存 7 | } 8 | 9 | // 获取所有书签 10 | async getAllBookmarks() { 11 | const cacheKey = 'all_bookmarks'; 12 | const cached = this.cache.get(cacheKey); 13 | 14 | if (cached && Date.now() - cached.timestamp < this.cacheExpiry) { 15 | return cached.data; 16 | } 17 | 18 | try { 19 | const bookmarks = await chrome.bookmarks.getTree(); 20 | this.cache.set(cacheKey, { 21 | data: bookmarks, 22 | timestamp: Date.now() 23 | }); 24 | return bookmarks; 25 | } catch (error) { 26 | console.error('获取书签失败:', error); 27 | throw new Error('无法获取书签数据'); 28 | } 29 | } 30 | 31 | // 扁平化书签树 32 | flattenBookmarks(bookmarkTree) { 33 | const result = []; 34 | 35 | function traverse(nodes, parentPath = '') { 36 | for (const node of nodes) { 37 | if (node.url) { 38 | // 这是一个书签 39 | result.push({ 40 | ...node, 41 | parentPath: parentPath, 42 | type: 'bookmark' 43 | }); 44 | } else if (node.children) { 45 | // 这是一个文件夹 46 | const currentPath = parentPath ? `${parentPath}/${node.title}` : node.title; 47 | result.push({ 48 | ...node, 49 | parentPath: parentPath, 50 | type: 'folder', 51 | path: currentPath 52 | }); 53 | traverse(node.children, currentPath); 54 | } 55 | } 56 | } 57 | 58 | traverse(bookmarkTree); 59 | return result; 60 | } 61 | 62 | // 获取扁平化的书签列表 63 | async getFlatBookmarks() { 64 | const bookmarks = await this.getAllBookmarks(); 65 | return this.flattenBookmarks(bookmarks); 66 | } 67 | 68 | // 按分类获取书签 69 | async getBookmarksByCategory() { 70 | const flatBookmarks = await this.getFlatBookmarks(); 71 | const categories = {}; 72 | 73 | for (const bookmark of flatBookmarks) { 74 | if (bookmark.type === 'bookmark') { 75 | const category = bookmark.parentPath || '未分类'; 76 | if (!categories[category]) { 77 | categories[category] = []; 78 | } 79 | categories[category].push(bookmark); 80 | } 81 | } 82 | 83 | return categories; 84 | } 85 | 86 | // 搜索书签 87 | async searchBookmarks(query, options = {}) { 88 | try { 89 | if (!query || query.trim().length === 0) { 90 | return []; 91 | } 92 | 93 | const results = await chrome.bookmarks.search(query); 94 | let bookmarks = results.filter(item => item.url); // 只返回书签,不包括文件夹 95 | 96 | // 应用过滤选项 97 | if (options.category) { 98 | const flatBookmarks = await this.getFlatBookmarks(); 99 | const categoryBookmarks = flatBookmarks.filter(b => 100 | b.type === 'bookmark' && b.parentPath === options.category 101 | ); 102 | const categoryIds = new Set(categoryBookmarks.map(b => b.id)); 103 | bookmarks = bookmarks.filter(b => categoryIds.has(b.id)); 104 | } 105 | 106 | // 按相关性排序 107 | bookmarks.sort((a, b) => { 108 | const aScore = this.calculateRelevanceScore(a, query); 109 | const bScore = this.calculateRelevanceScore(b, query); 110 | return bScore - aScore; 111 | }); 112 | 113 | return bookmarks.slice(0, options.limit || 50); 114 | } catch (error) { 115 | console.error('搜索书签失败:', error); 116 | throw new Error('搜索失败'); 117 | } 118 | } 119 | 120 | // 计算相关性分数 121 | calculateRelevanceScore(bookmark, query) { 122 | const queryLower = query.toLowerCase(); 123 | const title = bookmark.title.toLowerCase(); 124 | const url = bookmark.url.toLowerCase(); 125 | 126 | let score = 0; 127 | 128 | // 标题完全匹配 129 | if (title === queryLower) score += 100; 130 | // 标题开头匹配 131 | else if (title.startsWith(queryLower)) score += 50; 132 | // 标题包含 133 | else if (title.includes(queryLower)) score += 25; 134 | 135 | // URL匹配 136 | if (url.includes(queryLower)) score += 10; 137 | 138 | return score; 139 | } 140 | 141 | // 创建书签 142 | async createBookmark(bookmark) { 143 | try { 144 | const newBookmark = await chrome.bookmarks.create({ 145 | title: bookmark.title, 146 | url: bookmark.url, 147 | parentId: bookmark.parentId || '1' // 默认添加到书签栏 148 | }); 149 | 150 | this.clearCache(); 151 | return newBookmark; 152 | } catch (error) { 153 | console.error('创建书签失败:', error); 154 | throw new Error('无法创建书签'); 155 | } 156 | } 157 | 158 | // 更新书签 159 | async updateBookmark(id, updates) { 160 | try { 161 | const updatedBookmark = await chrome.bookmarks.update(id, updates); 162 | this.clearCache(); 163 | return updatedBookmark; 164 | } catch (error) { 165 | console.error('更新书签失败:', error); 166 | throw new Error('无法更新书签'); 167 | } 168 | } 169 | 170 | // 删除书签 171 | async deleteBookmark(id) { 172 | try { 173 | await chrome.bookmarks.remove(id); 174 | this.clearCache(); 175 | return true; 176 | } catch (error) { 177 | console.error('删除书签失败:', error); 178 | throw new Error('无法删除书签'); 179 | } 180 | } 181 | 182 | // 移动书签 183 | async moveBookmark(id, destination) { 184 | try { 185 | const movedBookmark = await chrome.bookmarks.move(id, destination); 186 | this.clearCache(); 187 | return movedBookmark; 188 | } catch (error) { 189 | console.error('移动书签失败:', error); 190 | throw new Error('无法移动书签'); 191 | } 192 | } 193 | 194 | // 批量移动书签 195 | async moveBookmarks(bookmarkIds, parentId) { 196 | try { 197 | const results = []; 198 | for (const id of bookmarkIds) { 199 | const result = await chrome.bookmarks.move(id, { parentId }); 200 | results.push(result); 201 | } 202 | this.clearCache(); 203 | return results; 204 | } catch (error) { 205 | console.error('批量移动书签失败:', error); 206 | throw new Error('无法批量移动书签'); 207 | } 208 | } 209 | 210 | // 创建文件夹 211 | async createFolder(title, parentId = '1') { 212 | try { 213 | const folder = await chrome.bookmarks.create({ 214 | title: title, 215 | parentId: parentId 216 | }); 217 | this.clearCache(); 218 | return folder; 219 | } catch (error) { 220 | console.error('创建文件夹失败:', error); 221 | throw new Error('无法创建文件夹'); 222 | } 223 | } 224 | 225 | // 查找文件夹 226 | async findFolder(title) { 227 | try { 228 | const results = await chrome.bookmarks.search({ title }); 229 | return results.find(item => !item.url); // 文件夹没有URL 230 | } catch (error) { 231 | console.error('查找文件夹失败:', error); 232 | return null; 233 | } 234 | } 235 | 236 | // 查找或创建文件夹 237 | async findOrCreateFolder(title, parentId = '1') { 238 | let folder = await this.findFolder(title); 239 | if (!folder) { 240 | folder = await this.createFolder(title, parentId); 241 | } 242 | return folder; 243 | } 244 | 245 | // 获取书签统计信息 246 | async getBookmarkStats() { 247 | try { 248 | const flatBookmarks = await this.getFlatBookmarks(); 249 | const bookmarks = flatBookmarks.filter(item => item.type === 'bookmark'); 250 | const folders = flatBookmarks.filter(item => item.type === 'folder'); 251 | 252 | // 按文件夹统计书签数量 253 | const folderStats = {}; 254 | for (const bookmark of bookmarks) { 255 | const category = bookmark.parentPath || '未分类'; 256 | folderStats[category] = (folderStats[category] || 0) + 1; 257 | } 258 | 259 | // 最近添加的书签(如果有dateAdded字段) 260 | const recentBookmarks = bookmarks 261 | .filter(b => b.dateAdded) 262 | .sort((a, b) => b.dateAdded - a.dateAdded) 263 | .slice(0, 10); 264 | 265 | return { 266 | totalBookmarks: bookmarks.length, 267 | totalFolders: folders.length, 268 | folderStats: folderStats, 269 | recentBookmarks: recentBookmarks, 270 | lastUpdated: Date.now() 271 | }; 272 | } catch (error) { 273 | console.error('获取统计信息失败:', error); 274 | throw new Error('无法获取统计信息'); 275 | } 276 | } 277 | 278 | // 导出书签 279 | async exportBookmarks(format = 'json') { 280 | try { 281 | const bookmarks = await this.getAllBookmarks(); 282 | const stats = await this.getBookmarkStats(); 283 | 284 | const exportData = { 285 | version: '1.0', 286 | timestamp: new Date().toISOString(), 287 | bookmarks: bookmarks, 288 | stats: stats, 289 | metadata: { 290 | exportFormat: format, 291 | extensionVersion: chrome.runtime.getManifest().version 292 | } 293 | }; 294 | 295 | if (format === 'json') { 296 | return JSON.stringify(exportData, null, 2); 297 | } else if (format === 'html') { 298 | return this.convertToHtml(bookmarks); 299 | } 300 | 301 | return exportData; 302 | } catch (error) { 303 | console.error('导出书签失败:', error); 304 | throw new Error('无法导出书签'); 305 | } 306 | } 307 | 308 | // 转换为HTML格式 309 | convertToHtml(bookmarks) { 310 | let html = ` 311 | 312 | Bookmarks 313 |

      Bookmarks

      314 |

      315 | `; 316 | 317 | function traverse(nodes, level = 0) { 318 | for (const node of nodes) { 319 | if (node.url) { 320 | html += `${' '.repeat(level)}

      ${node.title}\n`; 321 | } else if (node.children) { 322 | html += `${' '.repeat(level)}

      ${node.title}

      \n`; 323 | html += `${' '.repeat(level)}

      \n`; 324 | traverse(node.children, level + 1); 325 | html += `${' '.repeat(level)}

      \n`; 326 | } 327 | } 328 | } 329 | 330 | traverse(bookmarks); 331 | html += '

      '; 332 | return html; 333 | } 334 | 335 | // 清除缓存 336 | clearCache() { 337 | this.cache.clear(); 338 | } 339 | 340 | // 验证书签URL 341 | isValidUrl(url) { 342 | try { 343 | new URL(url); 344 | return true; 345 | } catch { 346 | return false; 347 | } 348 | } 349 | 350 | // 获取网站图标 351 | getFaviconUrl(url) { 352 | try { 353 | const domain = new URL(url).hostname; 354 | return `https://www.google.com/s2/favicons?domain=${domain}`; 355 | } catch { 356 | return 'data:image/svg+xml,'; 357 | } 358 | } 359 | 360 | // 分析书签URL模式 361 | analyzeUrlPatterns() { 362 | return this.getFlatBookmarks().then(bookmarks => { 363 | const domains = {}; 364 | const protocols = {}; 365 | 366 | bookmarks 367 | .filter(b => b.type === 'bookmark' && b.url) 368 | .forEach(bookmark => { 369 | try { 370 | const url = new URL(bookmark.url); 371 | 372 | // 统计域名 373 | domains[url.hostname] = (domains[url.hostname] || 0) + 1; 374 | 375 | // 统计协议 376 | protocols[url.protocol] = (protocols[url.protocol] || 0) + 1; 377 | } catch (error) { 378 | // 忽略无效URL 379 | } 380 | }); 381 | 382 | return { 383 | domains: Object.entries(domains) 384 | .sort(([,a], [,b]) => b - a) 385 | .slice(0, 20), // 前20个域名 386 | protocols: protocols 387 | }; 388 | }); 389 | } 390 | } 391 | 392 | // 导出单例实例 393 | const bookmarkService = new BookmarkService(); 394 | export default bookmarkService; -------------------------------------------------------------------------------- /extensions/organize/services/bookmarkService.js: -------------------------------------------------------------------------------- 1 | // bookmarkService.js - 书签管理服务 2 | 3 | class BookmarkService { 4 | constructor() { 5 | this.cache = new Map(); 6 | this.cacheExpiry = 5 * 60 * 1000; // 5分钟缓存 7 | } 8 | 9 | // 获取所有书签 10 | async getAllBookmarks() { 11 | const cacheKey = 'all_bookmarks'; 12 | const cached = this.cache.get(cacheKey); 13 | 14 | if (cached && Date.now() - cached.timestamp < this.cacheExpiry) { 15 | return cached.data; 16 | } 17 | 18 | try { 19 | const bookmarks = await chrome.bookmarks.getTree(); 20 | this.cache.set(cacheKey, { 21 | data: bookmarks, 22 | timestamp: Date.now() 23 | }); 24 | return bookmarks; 25 | } catch (error) { 26 | console.error('获取书签失败:', error); 27 | throw new Error('无法获取书签数据'); 28 | } 29 | } 30 | 31 | // 扁平化书签树 32 | flattenBookmarks(bookmarkTree) { 33 | const result = []; 34 | 35 | function traverse(nodes, parentPath = '') { 36 | for (const node of nodes) { 37 | if (node.url) { 38 | // 这是一个书签 39 | result.push({ 40 | ...node, 41 | parentPath: parentPath, 42 | type: 'bookmark' 43 | }); 44 | } else if (node.children) { 45 | // 这是一个文件夹 46 | const currentPath = parentPath ? `${parentPath}/${node.title}` : node.title; 47 | result.push({ 48 | ...node, 49 | parentPath: parentPath, 50 | type: 'folder', 51 | path: currentPath 52 | }); 53 | traverse(node.children, currentPath); 54 | } 55 | } 56 | } 57 | 58 | traverse(bookmarkTree); 59 | return result; 60 | } 61 | 62 | // 获取扁平化的书签列表 63 | async getFlatBookmarks() { 64 | const bookmarks = await this.getAllBookmarks(); 65 | return this.flattenBookmarks(bookmarks); 66 | } 67 | 68 | // 按分类获取书签 69 | async getBookmarksByCategory() { 70 | const flatBookmarks = await this.getFlatBookmarks(); 71 | const categories = {}; 72 | 73 | for (const bookmark of flatBookmarks) { 74 | if (bookmark.type === 'bookmark') { 75 | const category = bookmark.parentPath || '未分类'; 76 | if (!categories[category]) { 77 | categories[category] = []; 78 | } 79 | categories[category].push(bookmark); 80 | } 81 | } 82 | 83 | return categories; 84 | } 85 | 86 | // 搜索书签 87 | async searchBookmarks(query, options = {}) { 88 | try { 89 | if (!query || query.trim().length === 0) { 90 | return []; 91 | } 92 | 93 | const results = await chrome.bookmarks.search(query); 94 | let bookmarks = results.filter(item => item.url); // 只返回书签,不包括文件夹 95 | 96 | // 应用过滤选项 97 | if (options.category) { 98 | const flatBookmarks = await this.getFlatBookmarks(); 99 | const categoryBookmarks = flatBookmarks.filter(b => 100 | b.type === 'bookmark' && b.parentPath === options.category 101 | ); 102 | const categoryIds = new Set(categoryBookmarks.map(b => b.id)); 103 | bookmarks = bookmarks.filter(b => categoryIds.has(b.id)); 104 | } 105 | 106 | // 按相关性排序 107 | bookmarks.sort((a, b) => { 108 | const aScore = this.calculateRelevanceScore(a, query); 109 | const bScore = this.calculateRelevanceScore(b, query); 110 | return bScore - aScore; 111 | }); 112 | 113 | return bookmarks.slice(0, options.limit || 50); 114 | } catch (error) { 115 | console.error('搜索书签失败:', error); 116 | throw new Error('搜索失败'); 117 | } 118 | } 119 | 120 | // 计算相关性分数 121 | calculateRelevanceScore(bookmark, query) { 122 | const queryLower = query.toLowerCase(); 123 | const title = bookmark.title.toLowerCase(); 124 | const url = bookmark.url.toLowerCase(); 125 | 126 | let score = 0; 127 | 128 | // 标题完全匹配 129 | if (title === queryLower) score += 100; 130 | // 标题开头匹配 131 | else if (title.startsWith(queryLower)) score += 50; 132 | // 标题包含 133 | else if (title.includes(queryLower)) score += 25; 134 | 135 | // URL匹配 136 | if (url.includes(queryLower)) score += 10; 137 | 138 | return score; 139 | } 140 | 141 | // 创建书签 142 | async createBookmark(bookmark) { 143 | try { 144 | const newBookmark = await chrome.bookmarks.create({ 145 | title: bookmark.title, 146 | url: bookmark.url, 147 | parentId: bookmark.parentId || '1' // 默认添加到书签栏 148 | }); 149 | 150 | this.clearCache(); 151 | return newBookmark; 152 | } catch (error) { 153 | console.error('创建书签失败:', error); 154 | throw new Error('无法创建书签'); 155 | } 156 | } 157 | 158 | // 更新书签 159 | async updateBookmark(id, updates) { 160 | try { 161 | const updatedBookmark = await chrome.bookmarks.update(id, updates); 162 | this.clearCache(); 163 | return updatedBookmark; 164 | } catch (error) { 165 | console.error('更新书签失败:', error); 166 | throw new Error('无法更新书签'); 167 | } 168 | } 169 | 170 | // 删除书签 171 | async deleteBookmark(id) { 172 | try { 173 | await chrome.bookmarks.remove(id); 174 | this.clearCache(); 175 | return true; 176 | } catch (error) { 177 | console.error('删除书签失败:', error); 178 | throw new Error('无法删除书签'); 179 | } 180 | } 181 | 182 | // 移动书签 183 | async moveBookmark(id, destination) { 184 | try { 185 | const movedBookmark = await chrome.bookmarks.move(id, destination); 186 | this.clearCache(); 187 | return movedBookmark; 188 | } catch (error) { 189 | console.error('移动书签失败:', error); 190 | throw new Error('无法移动书签'); 191 | } 192 | } 193 | 194 | // 批量移动书签 195 | async moveBookmarks(bookmarkIds, parentId) { 196 | try { 197 | const results = []; 198 | for (const id of bookmarkIds) { 199 | const result = await chrome.bookmarks.move(id, { parentId }); 200 | results.push(result); 201 | } 202 | this.clearCache(); 203 | return results; 204 | } catch (error) { 205 | console.error('批量移动书签失败:', error); 206 | throw new Error('无法批量移动书签'); 207 | } 208 | } 209 | 210 | // 创建文件夹 211 | async createFolder(title, parentId = '1') { 212 | try { 213 | const folder = await chrome.bookmarks.create({ 214 | title: title, 215 | parentId: parentId 216 | }); 217 | this.clearCache(); 218 | return folder; 219 | } catch (error) { 220 | console.error('创建文件夹失败:', error); 221 | throw new Error('无法创建文件夹'); 222 | } 223 | } 224 | 225 | // 查找文件夹 226 | async findFolder(title) { 227 | try { 228 | const results = await chrome.bookmarks.search({ title }); 229 | return results.find(item => !item.url); // 文件夹没有URL 230 | } catch (error) { 231 | console.error('查找文件夹失败:', error); 232 | return null; 233 | } 234 | } 235 | 236 | // 查找或创建文件夹 237 | async findOrCreateFolder(title, parentId = '1') { 238 | let folder = await this.findFolder(title); 239 | if (!folder) { 240 | folder = await this.createFolder(title, parentId); 241 | } 242 | return folder; 243 | } 244 | 245 | // 获取书签统计信息 246 | async getBookmarkStats() { 247 | try { 248 | const flatBookmarks = await this.getFlatBookmarks(); 249 | const bookmarks = flatBookmarks.filter(item => item.type === 'bookmark'); 250 | const folders = flatBookmarks.filter(item => item.type === 'folder'); 251 | 252 | // 按文件夹统计书签数量 253 | const folderStats = {}; 254 | for (const bookmark of bookmarks) { 255 | const category = bookmark.parentPath || '未分类'; 256 | folderStats[category] = (folderStats[category] || 0) + 1; 257 | } 258 | 259 | // 最近添加的书签(如果有dateAdded字段) 260 | const recentBookmarks = bookmarks 261 | .filter(b => b.dateAdded) 262 | .sort((a, b) => b.dateAdded - a.dateAdded) 263 | .slice(0, 10); 264 | 265 | return { 266 | totalBookmarks: bookmarks.length, 267 | totalFolders: folders.length, 268 | folderStats: folderStats, 269 | recentBookmarks: recentBookmarks, 270 | lastUpdated: Date.now() 271 | }; 272 | } catch (error) { 273 | console.error('获取统计信息失败:', error); 274 | throw new Error('无法获取统计信息'); 275 | } 276 | } 277 | 278 | // 导出书签 279 | async exportBookmarks(format = 'json') { 280 | try { 281 | const bookmarks = await this.getAllBookmarks(); 282 | const stats = await this.getBookmarkStats(); 283 | 284 | const exportData = { 285 | version: '1.0', 286 | timestamp: new Date().toISOString(), 287 | bookmarks: bookmarks, 288 | stats: stats, 289 | metadata: { 290 | exportFormat: format, 291 | extensionVersion: chrome.runtime.getManifest().version 292 | } 293 | }; 294 | 295 | if (format === 'json') { 296 | return JSON.stringify(exportData, null, 2); 297 | } else if (format === 'html') { 298 | return this.convertToHtml(bookmarks); 299 | } 300 | 301 | return exportData; 302 | } catch (error) { 303 | console.error('导出书签失败:', error); 304 | throw new Error('无法导出书签'); 305 | } 306 | } 307 | 308 | // 转换为HTML格式 309 | convertToHtml(bookmarks) { 310 | let html = ` 311 | 312 | Bookmarks 313 |

      Bookmarks

      314 |

      315 | `; 316 | 317 | function traverse(nodes, level = 0) { 318 | for (const node of nodes) { 319 | if (node.url) { 320 | html += `${' '.repeat(level)}

      ${node.title}\n`; 321 | } else if (node.children) { 322 | html += `${' '.repeat(level)}

      ${node.title}

      \n`; 323 | html += `${' '.repeat(level)}

      \n`; 324 | traverse(node.children, level + 1); 325 | html += `${' '.repeat(level)}

      \n`; 326 | } 327 | } 328 | } 329 | 330 | traverse(bookmarks); 331 | html += '

      '; 332 | return html; 333 | } 334 | 335 | // 清除缓存 336 | clearCache() { 337 | this.cache.clear(); 338 | } 339 | 340 | // 验证书签URL 341 | isValidUrl(url) { 342 | try { 343 | new URL(url); 344 | return true; 345 | } catch { 346 | return false; 347 | } 348 | } 349 | 350 | // 获取网站图标 351 | getFaviconUrl(url) { 352 | try { 353 | const domain = new URL(url).hostname; 354 | return `https://www.google.com/s2/favicons?domain=${domain}`; 355 | } catch { 356 | return 'data:image/svg+xml,'; 357 | } 358 | } 359 | 360 | // 分析书签URL模式 361 | analyzeUrlPatterns() { 362 | return this.getFlatBookmarks().then(bookmarks => { 363 | const domains = {}; 364 | const protocols = {}; 365 | 366 | bookmarks 367 | .filter(b => b.type === 'bookmark' && b.url) 368 | .forEach(bookmark => { 369 | try { 370 | const url = new URL(bookmark.url); 371 | 372 | // 统计域名 373 | domains[url.hostname] = (domains[url.hostname] || 0) + 1; 374 | 375 | // 统计协议 376 | protocols[url.protocol] = (protocols[url.protocol] || 0) + 1; 377 | } catch (error) { 378 | // 忽略无效URL 379 | } 380 | }); 381 | 382 | return { 383 | domains: Object.entries(domains) 384 | .sort(([,a], [,b]) => b - a) 385 | .slice(0, 20), // 前20个域名 386 | protocols: protocols 387 | }; 388 | }); 389 | } 390 | } 391 | 392 | // 导出单例实例 393 | const bookmarkService = new BookmarkService(); 394 | export default bookmarkService; -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.4.21 — 2025-10-21 4 | 5 | - Organize-only: localized short description across EN/zh-CN/zh-TW/ru to focus on bookmark organizing; removed any mention of New Tab. 6 | - Manifest (organize-only): `description` and `action.default_title` now use i18n messages (`__MSG_appDesc__`, `__MSG_actionTitle__`) with `default_locale=en`. 7 | - Docs: clarified variant descriptions earlier; no functional changes in features. 8 | 9 | ## v1.4.19 — 2025-10-17 10 | 11 | - i18n: remove unused `search.engine*` messages; keep concise default-search note key. 12 | - Options: add inline note clarifying default search uses `chrome.search.query` (no engine switching). 13 | - Manifest: trim `host_permissions` by removing `https://www.google.com/*` to reduce review risk. 14 | - Locales: short description aligned to “Minimal new tab: bookmarks, smart categorization, wallpaper & weather, default search”. 15 | 16 | ## v1.4.18 — 2025-10-17 17 | 18 | - New Tab: block default form submission on the search form (`action="#"` + `onsubmit="return false"`), preventing accidental empty query and trailing `?` in URL. 19 | - No behavior change for actual search flow; form submission remains handled by JS (bookmarks, URL jump, or `chrome.search.query`). 20 | 21 | ## v1.4.17 — 2025-10-17 22 | 23 | - Compliance: New Tab search now uses `chrome.search.query` and strictly follows the browser's default search provider. 24 | - UI: remove search engine selector and related preferences from New Tab. 25 | - Background: remove default `searchEngine` initialization; no engine override or storage. 26 | - Manifest: add `search` permission to enable the Search API; no `search_provider` override. 27 | - Docs: update store review notes and permission justifications accordingly. 28 | 29 | ## v1.4.16 — 2025-10-16 30 | 31 | - New Tab: set 60s primary instance to `https://60api.09cdn.xyz`; fallback order unchanged. 32 | - No breaking changes; functionality remains the same. 33 | 34 | ## v1.4.15 — 2025-10-15 35 | 36 | - Permissions: refine default `host_permissions` to a precise whitelist (Bing, Google favicons, Open-Meteo, 60s instances, GitHub API and raw). 37 | - Optional host permissions: declare ``, `http://localhost:*/*`, `api.openai.com`, `api.deepseek.com` as optional; requested only when features are used. 38 | - Dead-link scan: add runtime permission request before scanning to ensure cross-origin checks run only with explicit user consent. 39 | - No breaking changes; existing settings remain intact; store review friendliness improved. 40 | 41 | ## v1.4.14 — 2025-10-15 42 | 43 | - i18n: localize manifest fields (`name`, `short_name`, `description`, `action.default_title`) using `__MSG_*__` and set `default_locale=en`. 44 | - Locales: add `_locales/en`, `_locales/zh_CN`, `_locales/zh_TW`, `_locales/ru` with `messages.json`. 45 | - CI: release packaging now includes `_locales/` so stores detect supported languages. 46 | - New Tab: prefer Bing official UHD “desktop wallpaper” source by default, fallback to 60s multi-instances; keep daily cache update-on-success behavior. 47 | - No breaking changes; existing settings migrate automatically. 48 | 49 | ## v1.4.10 — 2025-10-14 50 | 51 | - Options: replace header language select with icon button; set icon color to white and size to 16px; slightly increase button padding for better click comfort. 52 | - Options: switch menu show/hide to class-based `.open`; close on outside click and Esc; keep menu hidden by default via CSS; set `aria-expanded` on toggle. 53 | - No breaking changes; documentation unchanged. 54 | 55 | ## v1.4.9 — 2025-10-14 56 | 57 | - New Tab: autofocus search input and add JS fallback focus to ensure input is focused on open across browsers. 58 | - No breaking changes; documentation unchanged. 59 | 60 | ## v1.4.7 — 2025-10-14 61 | 62 | - Docs: split README into bilingual files; English default (`README.md`) with top language switch; add Chinese file (`README.zh-CN.md`). 63 | - Options: fix footer version text to use i18n (`footer.app`) and append version; ensure language change triggers a delayed refresh for non-reactive texts. 64 | - i18n: add missing `about.*` keys across locales for Options page content. 65 | - No breaking changes. 66 | 67 | ## v1.4.6 — 2025-10-14 68 | 69 | - Options: add clearer prompt textareas with monospace styling and better readability. 70 | - Options: show placeholder hints under each prompt, including `{{language}}`, `{{itemsJson}}`, and `{{categoriesJson}}` where applicable. 71 | - Options: add right-aligned action buttons — Copy and Reset to Default — for both prompts. 72 | - Options: wire copy to clipboard with fallback, and reset to default templates with immediate persistence. 73 | - Background: continues to respect user templates from `chrome.storage.sync`, falling back when empty. 74 | - No breaking changes; existing settings migrate automatically. 75 | 76 | ## v1.4.4 — 2025-10-13 77 | 78 | - New Tab: header glass overlay now follows content width with 8px side padding for better aesthetics and readability on wallpapers. 79 | - No functional changes; README unchanged. 80 | 81 | ## v1.4.3 — 2025-10-13 82 | 83 | - Options: restore AI connectivity “测试链接” button and inline result. 84 | - Options: change “🤖 AI 全量归类” to accent primary style for parity with “⚡ 自动整理”. 85 | - Background: wire classification language (zh/en/auto) into default rules, auto classify, context menu quick classify, and AI mock suggestions; fallback folder name supports “其他/Others”. 86 | - UX: organize preview respects bilingual “其他/Others” when moving uncategorized items. 87 | - CI: release workflow triggers on `v*.*.*` tag; packaged zip includes `manifest.json`, `icons`, `src`, `services`, `README.md`, `LICENSE`. 88 | 89 | ## v1.3.2 — 2025-10-11 90 | 91 | - New Tab: change default search engine to Bing. 92 | - Install: set `searchEngine='bing'` in local storage on first install when unset. 93 | - Docs: README notes Bing as the default search engine (bilingual). 94 | 95 | --- 96 | 97 | ## v1.3.1 — 2025-10-11 98 | 99 | - New Tab: enable Bing wallpaper by default; honors stored preference if present. 100 | - Resilience: add multiple fallback instances for 60s digest and Bing wallpaper API. 101 | - Fallback: wallpaper uses Bing official `HPImageArchive` when all 60s instances fail. 102 | - Options: default `wallpaperEnabled=true` so checkbox is checked on fresh installs. 103 | - Install: background initializes `wallpaperEnabled=true` only when unset. 104 | - Docs: README updated to reflect wallpaper default enabled and configurable items. 105 | 106 | --- 107 | 108 | ## v1.2.0 — 2025-10-11 109 | 110 | - New Tab: apply wallpaper to `body` for true fullscreen coverage with `background-size: cover`, center positioning, and mobile fallback (`background-attachment: scroll`) to avoid jitter. 111 | - Settings: add `wallpaperEnabled` switch in Options (default OFF), stored in `chrome.storage.sync`, consistent with Weather settings. 112 | - New Tab: remove the top-bar wallpaper toggle; the setting is now controlled exclusively via Options. 113 | - Readability: add subtle text shadows to time, subtitle, weather, and hint text; removed global overlay per feedback to keep page bright. 114 | - Perf: wallpaper cached for 6 hours; uses 60s Bing API. 115 | - Docs: add Weather feature description to README (Options toggle and inline summary). 116 | 117 | --- 118 | 119 | ## v1.1.0 — 2025-10-11 120 | 121 | - Popup: add concise top hint “点击书签切换分类” for manual adjustments. 122 | - Picker: unify category picker to a mini modal (`picker-dialog`) and style select (`picker-select`). 123 | - UX: set bookmark items to pointer cursor in preview for better discoverability. 124 | - i18n: add preview picker-related keys and shorten `preview.clickHint`. 125 | - Docs: update README with manual switching instructions. 126 | 127 | --- 128 | 129 | ## v1.0.6 — 2025-10-10 130 | 131 | - CI: fix release packaging to include `src/`, `services/`, `icons/`, and `manifest.json`. 132 | - Result: generated ZIP contains all runtime files and runs correctly. 133 | - No functional changes. 134 | 135 | --- 136 | ## v1.0.5 — 2025-10-10 137 | 138 | - README: point screenshot links to project root (`./home.png`, `./setting.png`, `./nav.png`). 139 | - Assets: move screenshots back to repository root for simpler local preview. 140 | - Docs: add `docs/index.html` for local screenshot preview (non-functional). 141 | - No functional changes. 142 | 143 | --- 144 | 145 | ## v1.0.4 — 2025-10-10 146 | 147 | - README: switch screenshot links to Raw GitHub URLs for reliable preview in any environment. 148 | - Pages: normalize header icon paths to root-absolute `/icons/...` for consistent loading. 149 | - No functional changes. 150 | 151 | --- 152 | 153 | ## v1.0.3 — 2025-10-10 154 | 155 | - Move `preview.html` to `src/pages/preview/index.html` and fix resource paths. 156 | - Create `assets/screenshots/` and relocate `home.png`, `setting.png`, `nav.png`. 157 | - Update README: add navigation screenshot, AI optimization notes (wait 2–3 min, keep UI open), and new project structure. 158 | - Options page: implement dynamic version reading from `manifest.json` (fallback in preview). 159 | - Bump extension version to `1.0.3` in `manifest.json`. 160 | 161 | --- 162 | 163 | ## v1.0.2 — 2025-10-10 164 | 165 | - Implement navigation across extension pages (Popup/Options/Preview). 166 | - Add top navigation bar and page links for easier access. 167 | - Improve UX with active-state highlighting and consistent routes. 168 | 169 | --- 170 | 171 | ## v0.1.0 — 2025-10-09 172 | 173 | - Initial public release of TidyMark (Chrome/Edge Manifest V3). 174 | - Bookmark backup/restore to JSON with one-click recovery. 175 | - Auto categorize based on keywords; manage “Other” for uncategorized items. 176 | - Category management UI for add/delete/edit. 177 | - Internationalization: English and Simplified Chinese. 178 | - Options page: AI provider/model configuration; blocks non-standard “reasoner” models. 179 | - Post-organize cleanup: automatically remove now-empty source folders. 180 | - Bilingual README and MIT License added. 181 | 182 | --- 183 | 184 | ## v0.1.0 — 2025-10-09(中文) 185 | 186 | - TidyMark 首次公开发行(Chrome/Edge Manifest V3)。 187 | - 支持书签 JSON 备份与一键恢复。 188 | - 基于关键词自动分类;“其他”目录批量管理未分类书签。 189 | - 分类管理界面:增/删/改。 190 | - 国际化:英文与简体中文。 191 | - 选项页:AI 服务商/模型配置;屏蔽非标准 “reasoner/思考型” 模型。 192 | - 整理后清理:自动删除已变为空的源目录。 193 | - 新增双语 README 与 MIT 许可证。 194 | 195 | --- 196 | 197 | ## v1.0.4 — 2025-10-10(中文) 198 | 199 | - README 截图链接改为 Raw GitHub 地址,确保各环境可预览。 200 | - 页面头部图标路径统一为根绝对路径 `/icons/...`,提升加载一致性。 201 | - 无功能改动。 202 | 203 | --- 204 | 205 | ## v1.0.5 — 2025-10-10(中文) 206 | 207 | - README:将截图链接改为根目录相对路径(`./home.png`、`./setting.png`、`./nav.png`)。 208 | - 资源:将截图文件移回仓库根目录,便于本地预览。 209 | - 文档:新增 `docs/index.html` 用于本地截图预览(非功能改动)。 210 | - 无功能改动。 211 | 212 | --- 213 | 214 | ## v1.0.6 — 2025-10-10(中文) 215 | 216 | - CI:修复 Release 打包清单,包含 `src/`、`services/`、`icons/` 与 `manifest.json`。 217 | - 结果:生成的 ZIP 包含完整运行文件,安装后可正常运行。 218 | - 无功能改动。 219 | 220 | --- 221 | 222 | ## v1.0.3 — 2025-10-10(中文) 223 | 224 | - 迁移预览页到 `src/pages/preview/index.html` 并修正资源引用路径。 225 | - 新建 `assets/screenshots/` 并归档 `home.png`、`setting.png`、`nav.png`。 226 | - 更新 README:新增导航截图与 AI 优化注意事项(等待 2–3 分钟且界面保持开启),同步项目结构。 227 | - 选项页增加动态版本号读取(扩展环境读取 `manifest.json`,预览环境回退)。 228 | - 将扩展版本更新为 `1.0.3`。 229 | 230 | --- 231 | 232 | ## v1.0.2 — 2025-10-10(中文) 233 | 234 | - 实现扩展内导航(弹窗/选项/预览页)。 235 | - 增加顶部导航栏与页面链接,提升访问便捷性。 236 | - 优化交互:当前页面高亮与一致的路由跳转。 237 | 238 | --- 239 | 240 | ## v1.1.0 — 2025-10-11(中文) 241 | 242 | - 弹窗:在预览弹窗顶部加入简短提示“点击书签切换分类”。 243 | - 选择器:统一为小型模态框(`picker-dialog`),并美化选择控件(`picker-select`)。 244 | - 交互:预览列表中的书签项鼠标样式改为指针,提升可发现性。 245 | - 国际化:补充预览选择相关键值,精简 `preview.clickHint` 文案。 246 | - 文档:更新 README,新增手动切换分类的说明。 247 | --- 248 | 249 | ## v1.2.0 — 2025-10-11(中文) 250 | 251 | - 新标签页:将壁纸应用到 `body` 实现真正的全屏铺满,采用 `background-size: cover` 与居中显示;在小屏/移动端禁用 `background-attachment: fixed`,避免滚动抖动。 252 | - 设置页:新增 `wallpaperEnabled` 开关(默认关闭),使用 `chrome.storage.sync` 持久化,与天气设置保持一致。 253 | - 新标签页:移除顶部壁纸切换按钮,统一由设置页控制。 254 | - 可读性:为时间、副标题、天气与提示文字增加轻微 `text-shadow`;根据反馈移除了全局暗层,保持页面更明亮。 255 | - 性能:壁纸缓存 6 小时,来源为 60s Bing API。 256 | - 文档:补充 README 的天气功能说明(选项页开关与导航页摘要)。 257 | 258 | --- 259 | 260 | ## v1.2.1 — 2025-10-11 261 | 262 | - New Tab: fix `showBookmarks` preference parsing and consistency across environments (Chrome storage vs local preview). 263 | - Visibility: add global `[hidden] { display: none !important; }` to ensure hidden elements are truly hidden. 264 | - UX: add a centered one-line placeholder text when bookmarks are hidden. 265 | - Footer: remove bottom TidyMark brand block from the New Tab page. 266 | - Sync: listen to storage changes to reflect bookmark visibility toggles in real time. 267 | 268 | --- 269 | 270 | ## v1.2.1 — 2025-10-11(中文) 271 | 272 | - 新标签页:修复 `showBookmarks` 偏好解析不一致问题,统一跨环境(扩展/本地预览)。 273 | - 显示控制:新增全局 `[hidden] { display: none !important; }`,确保隐藏元素真正不可见。 274 | - 体验:在隐藏书签时显示居中的单行占位提示文案。 275 | - 页脚:移除新标签页底部的 TidyMark 品牌区块。 276 | - 同步:监听存储变更,书签显示开关可实时生效。 277 | ## [1.3.0] - 2025-10-11 278 | 279 | ### Added 280 | - 60s 读懂世界:整块区域作为单一链接,点击或键盘(Enter/Space)打开原文。 281 | - 60s 读懂世界:新增独立非聚焦透明度配置,可在设置页调节。 282 | - 书签搜索结果:展示顺序调整为优先显示在 60s 栏目之上。 283 | 284 | ### Changed 285 | - 搜索按钮样式统一为描边圆角胶囊,与整体 UI 保持一致。 286 | 287 | ### Fixed 288 | - 优化副标题与天气栏的并排显示与换行行为。 289 | ## v1.4.5 290 | 291 | - Drag UX improvements on newtab: 292 | - Clearer drop indicators with explicit top/bottom insertion bars. 293 | - More reliable placement using a 33% threshold (20–60px guard). 294 | - Enabled dragging for “60s 读懂世界” and “热门书签 Top N” modules within the main area. 295 | - Prevented cross-container drops into the bookmarks list to avoid misplacement. 296 | - No functional changes to data or features; README unchanged. -------------------------------------------------------------------------------- /services/storageService.js: -------------------------------------------------------------------------------- 1 | // storageService.js - 存储管理服务 2 | 3 | class StorageService { 4 | constructor() { 5 | this.cache = new Map(); 6 | this.listeners = new Map(); 7 | } 8 | 9 | // 获取存储数据 10 | async get(keys) { 11 | try { 12 | if (typeof keys === 'string') { 13 | keys = [keys]; 14 | } 15 | 16 | // 检查缓存 17 | if (Array.isArray(keys)) { 18 | const cached = {}; 19 | const uncachedKeys = []; 20 | 21 | for (const key of keys) { 22 | if (this.cache.has(key)) { 23 | cached[key] = this.cache.get(key); 24 | } else { 25 | uncachedKeys.push(key); 26 | } 27 | } 28 | 29 | if (uncachedKeys.length === 0) { 30 | return cached; 31 | } 32 | 33 | // 获取未缓存的数据 34 | const result = await chrome.storage.sync.get(uncachedKeys); 35 | 36 | // 更新缓存 37 | for (const [key, value] of Object.entries(result)) { 38 | this.cache.set(key, value); 39 | } 40 | 41 | return { ...cached, ...result }; 42 | } 43 | 44 | // 获取所有数据 45 | const result = await chrome.storage.sync.get(keys); 46 | 47 | // 更新缓存 48 | for (const [key, value] of Object.entries(result)) { 49 | this.cache.set(key, value); 50 | } 51 | 52 | return result; 53 | } catch (error) { 54 | console.error('获取存储数据失败:', error); 55 | throw new Error('无法获取存储数据'); 56 | } 57 | } 58 | 59 | // 设置存储数据 60 | async set(data) { 61 | try { 62 | await chrome.storage.sync.set(data); 63 | 64 | // 更新缓存 65 | for (const [key, value] of Object.entries(data)) { 66 | this.cache.set(key, value); 67 | } 68 | 69 | // 触发监听器 70 | this.notifyListeners(data); 71 | 72 | return true; 73 | } catch (error) { 74 | console.error('设置存储数据失败:', error); 75 | throw new Error('无法设置存储数据'); 76 | } 77 | } 78 | 79 | // 删除存储数据 80 | async remove(keys) { 81 | try { 82 | if (typeof keys === 'string') { 83 | keys = [keys]; 84 | } 85 | 86 | await chrome.storage.sync.remove(keys); 87 | 88 | // 清除缓存 89 | for (const key of keys) { 90 | this.cache.delete(key); 91 | } 92 | 93 | return true; 94 | } catch (error) { 95 | console.error('删除存储数据失败:', error); 96 | throw new Error('无法删除存储数据'); 97 | } 98 | } 99 | 100 | // 清空所有存储数据 101 | async clear() { 102 | try { 103 | await chrome.storage.sync.clear(); 104 | this.cache.clear(); 105 | return true; 106 | } catch (error) { 107 | console.error('清空存储数据失败:', error); 108 | throw new Error('无法清空存储数据'); 109 | } 110 | } 111 | 112 | // 获取本地存储数据 113 | async getLocal(keys) { 114 | try { 115 | return await chrome.storage.local.get(keys); 116 | } catch (error) { 117 | console.error('获取本地存储数据失败:', error); 118 | throw new Error('无法获取本地存储数据'); 119 | } 120 | } 121 | 122 | // 设置本地存储数据 123 | async setLocal(data) { 124 | try { 125 | await chrome.storage.local.set(data); 126 | return true; 127 | } catch (error) { 128 | console.error('设置本地存储数据失败:', error); 129 | throw new Error('无法设置本地存储数据'); 130 | } 131 | } 132 | 133 | // 删除本地存储数据 134 | async removeLocal(keys) { 135 | try { 136 | if (typeof keys === 'string') { 137 | keys = [keys]; 138 | } 139 | await chrome.storage.local.remove(keys); 140 | return true; 141 | } catch (error) { 142 | console.error('删除本地存储数据失败:', error); 143 | throw new Error('无法删除本地存储数据'); 144 | } 145 | } 146 | 147 | // 获取存储使用情况 148 | async getUsage() { 149 | try { 150 | const syncUsage = await chrome.storage.sync.getBytesInUse(); 151 | const localUsage = await chrome.storage.local.getBytesInUse(); 152 | 153 | return { 154 | sync: { 155 | used: syncUsage, 156 | quota: chrome.storage.sync.QUOTA_BYTES, 157 | percentage: (syncUsage / chrome.storage.sync.QUOTA_BYTES) * 100 158 | }, 159 | local: { 160 | used: localUsage, 161 | quota: chrome.storage.local.QUOTA_BYTES, 162 | percentage: (localUsage / chrome.storage.local.QUOTA_BYTES) * 100 163 | } 164 | }; 165 | } catch (error) { 166 | console.error('获取存储使用情况失败:', error); 167 | throw new Error('无法获取存储使用情况'); 168 | } 169 | } 170 | 171 | // 设置管理 172 | async getSettings() { 173 | const defaultSettings = { 174 | autoBackup: true, 175 | backupPath: '', 176 | autoClassify: true, 177 | classificationRules: [], 178 | aiProvider: 'openai', 179 | aiApiKey: '', 180 | aiApiUrl: '', 181 | searchEnabled: true, 182 | showStats: true, 183 | theme: 'light', 184 | language: 'zh-CN', 185 | backupInterval: 24 * 60 * 60 * 1000, // 24小时 186 | maxBackups: 10, 187 | enableNotifications: true, 188 | debugMode: false 189 | }; 190 | 191 | const settings = await this.get(Object.keys(defaultSettings)); 192 | 193 | // 合并默认设置 194 | const result = {}; 195 | for (const [key, defaultValue] of Object.entries(defaultSettings)) { 196 | result[key] = settings[key] !== undefined ? settings[key] : defaultValue; 197 | } 198 | 199 | return result; 200 | } 201 | 202 | // 更新设置 203 | async updateSettings(updates) { 204 | const currentSettings = await this.getSettings(); 205 | const newSettings = { ...currentSettings, ...updates }; 206 | 207 | // 验证设置 208 | this.validateSettings(newSettings); 209 | 210 | await this.set(newSettings); 211 | return newSettings; 212 | } 213 | 214 | // 验证设置 215 | validateSettings(settings) { 216 | // 验证备份间隔 217 | if (settings.backupInterval < 60 * 60 * 1000) { // 最少1小时 218 | throw new Error('备份间隔不能少于1小时'); 219 | } 220 | 221 | // 验证最大备份数量 222 | if (settings.maxBackups < 1 || settings.maxBackups > 50) { 223 | throw new Error('最大备份数量必须在1-50之间'); 224 | } 225 | 226 | // 验证AI提供商 227 | const validProviders = ['openai', 'deepseek', 'custom']; 228 | if (!validProviders.includes(settings.aiProvider)) { 229 | throw new Error('无效的AI提供商'); 230 | } 231 | 232 | // 验证主题 233 | const validThemes = ['light', 'dark', 'auto']; 234 | if (!validThemes.includes(settings.theme)) { 235 | throw new Error('无效的主题设置'); 236 | } 237 | } 238 | 239 | // 备份管理 240 | async createBackup(data, type = 'manual') { 241 | try { 242 | const backup = { 243 | id: this.generateId(), 244 | type: type, // manual, auto, scheduled 245 | timestamp: Date.now(), 246 | data: data, 247 | size: JSON.stringify(data).length, 248 | version: chrome.runtime.getManifest().version 249 | }; 250 | 251 | // 获取现有备份 252 | const { backups = [] } = await this.getLocal(['backups']); 253 | 254 | // 添加新备份 255 | backups.unshift(backup); 256 | 257 | // 限制备份数量 258 | const settings = await this.getSettings(); 259 | const maxBackups = settings.maxBackups || 10; 260 | if (backups.length > maxBackups) { 261 | backups.splice(maxBackups); 262 | } 263 | 264 | // 保存备份列表 265 | await this.setLocal({ backups }); 266 | 267 | // 更新最后备份时间 268 | await this.set({ lastBackupTime: Date.now() }); 269 | 270 | return backup; 271 | } catch (error) { 272 | console.error('创建备份失败:', error); 273 | throw new Error('无法创建备份'); 274 | } 275 | } 276 | 277 | // 获取备份列表 278 | async getBackups() { 279 | try { 280 | const { backups = [] } = await this.getLocal(['backups']); 281 | return backups.sort((a, b) => b.timestamp - a.timestamp); 282 | } catch (error) { 283 | console.error('获取备份列表失败:', error); 284 | throw new Error('无法获取备份列表'); 285 | } 286 | } 287 | 288 | // 删除备份 289 | async deleteBackup(backupId) { 290 | try { 291 | const { backups = [] } = await this.getLocal(['backups']); 292 | const filteredBackups = backups.filter(backup => backup.id !== backupId); 293 | await this.setLocal({ backups: filteredBackups }); 294 | return true; 295 | } catch (error) { 296 | console.error('删除备份失败:', error); 297 | throw new Error('无法删除备份'); 298 | } 299 | } 300 | 301 | // 清理旧备份 302 | async cleanupOldBackups() { 303 | try { 304 | const settings = await this.getSettings(); 305 | const { backups = [] } = await this.getLocal(['backups']); 306 | 307 | const maxAge = 30 * 24 * 60 * 60 * 1000; // 30天 308 | const cutoffTime = Date.now() - maxAge; 309 | 310 | const validBackups = backups.filter(backup => 311 | backup.timestamp > cutoffTime || backup.type === 'manual' 312 | ); 313 | 314 | // 限制数量 315 | const maxBackups = settings.maxBackups || 10; 316 | if (validBackups.length > maxBackups) { 317 | validBackups.splice(maxBackups); 318 | } 319 | 320 | await this.setLocal({ backups: validBackups }); 321 | return validBackups.length; 322 | } catch (error) { 323 | console.error('清理旧备份失败:', error); 324 | throw new Error('无法清理旧备份'); 325 | } 326 | } 327 | 328 | // 数据迁移 329 | async migrateData(fromVersion, toVersion) { 330 | try { 331 | console.log(`数据迁移: ${fromVersion} -> ${toVersion}`); 332 | 333 | // 这里可以添加版本特定的迁移逻辑 334 | if (fromVersion < '1.0.0') { 335 | // 迁移旧版本数据 336 | await this.migrateFromLegacy(); 337 | } 338 | 339 | // 更新版本信息 340 | await this.set({ dataVersion: toVersion }); 341 | 342 | return true; 343 | } catch (error) { 344 | console.error('数据迁移失败:', error); 345 | throw new Error('数据迁移失败'); 346 | } 347 | } 348 | 349 | // 从旧版本迁移 350 | async migrateFromLegacy() { 351 | // 实现具体的迁移逻辑 352 | console.log('执行旧版本数据迁移...'); 353 | } 354 | 355 | // 添加存储监听器 356 | addListener(key, callback) { 357 | if (!this.listeners.has(key)) { 358 | this.listeners.set(key, new Set()); 359 | } 360 | this.listeners.get(key).add(callback); 361 | } 362 | 363 | // 移除存储监听器 364 | removeListener(key, callback) { 365 | if (this.listeners.has(key)) { 366 | this.listeners.get(key).delete(callback); 367 | } 368 | } 369 | 370 | // 通知监听器 371 | notifyListeners(changes) { 372 | for (const [key, value] of Object.entries(changes)) { 373 | if (this.listeners.has(key)) { 374 | for (const callback of this.listeners.get(key)) { 375 | try { 376 | callback(value, key); 377 | } catch (error) { 378 | console.error('监听器回调失败:', error); 379 | } 380 | } 381 | } 382 | } 383 | } 384 | 385 | // 生成唯一ID 386 | generateId() { 387 | return Date.now().toString(36) + Math.random().toString(36).substr(2); 388 | } 389 | 390 | // 导出所有数据 391 | async exportAllData() { 392 | try { 393 | const syncData = await chrome.storage.sync.get(); 394 | const localData = await chrome.storage.local.get(); 395 | 396 | return { 397 | version: chrome.runtime.getManifest().version, 398 | timestamp: new Date().toISOString(), 399 | sync: syncData, 400 | local: localData 401 | }; 402 | } catch (error) { 403 | console.error('导出数据失败:', error); 404 | throw new Error('无法导出数据'); 405 | } 406 | } 407 | 408 | // 导入数据 409 | async importData(data) { 410 | try { 411 | if (!data.version || !data.sync) { 412 | throw new Error('无效的数据格式'); 413 | } 414 | 415 | // 清空现有数据 416 | await this.clear(); 417 | await chrome.storage.local.clear(); 418 | 419 | // 导入同步数据 420 | if (data.sync && Object.keys(data.sync).length > 0) { 421 | await chrome.storage.sync.set(data.sync); 422 | } 423 | 424 | // 导入本地数据 425 | if (data.local && Object.keys(data.local).length > 0) { 426 | await chrome.storage.local.set(data.local); 427 | } 428 | 429 | // 清除缓存 430 | this.cache.clear(); 431 | 432 | return true; 433 | } catch (error) { 434 | console.error('导入数据失败:', error); 435 | throw new Error('无法导入数据'); 436 | } 437 | } 438 | 439 | // 获取存储统计信息 440 | async getStorageStats() { 441 | try { 442 | const usage = await this.getUsage(); 443 | const backups = await this.getBackups(); 444 | const settings = await this.getSettings(); 445 | 446 | return { 447 | usage: usage, 448 | backupCount: backups.length, 449 | lastBackupTime: settings.lastBackupTime, 450 | totalSize: usage.sync.used + usage.local.used, 451 | cacheSize: this.cache.size 452 | }; 453 | } catch (error) { 454 | console.error('获取存储统计失败:', error); 455 | throw new Error('无法获取存储统计'); 456 | } 457 | } 458 | 459 | // 清除缓存 460 | clearCache() { 461 | this.cache.clear(); 462 | } 463 | } 464 | 465 | // 导出单例实例 466 | const storageService = new StorageService(); 467 | 468 | // 监听存储变化 469 | if (typeof chrome !== 'undefined' && chrome.storage) { 470 | chrome.storage.onChanged.addListener((changes, areaName) => { 471 | console.log('存储变化:', changes, areaName); 472 | 473 | // 更新缓存 474 | if (areaName === 'sync') { 475 | for (const [key, change] of Object.entries(changes)) { 476 | if (change.newValue !== undefined) { 477 | storageService.cache.set(key, change.newValue); 478 | } else { 479 | storageService.cache.delete(key); 480 | } 481 | } 482 | } 483 | 484 | // 通知监听器 485 | const changedData = {}; 486 | for (const [key, change] of Object.entries(changes)) { 487 | changedData[key] = change.newValue; 488 | } 489 | storageService.notifyListeners(changedData); 490 | }); 491 | } 492 | 493 | export default storageService; -------------------------------------------------------------------------------- /extensions/organize/services/storageService.js: -------------------------------------------------------------------------------- 1 | // storageService.js - 存储管理服务 2 | 3 | class StorageService { 4 | constructor() { 5 | this.cache = new Map(); 6 | this.listeners = new Map(); 7 | } 8 | 9 | // 获取存储数据 10 | async get(keys) { 11 | try { 12 | if (typeof keys === 'string') { 13 | keys = [keys]; 14 | } 15 | 16 | // 检查缓存 17 | if (Array.isArray(keys)) { 18 | const cached = {}; 19 | const uncachedKeys = []; 20 | 21 | for (const key of keys) { 22 | if (this.cache.has(key)) { 23 | cached[key] = this.cache.get(key); 24 | } else { 25 | uncachedKeys.push(key); 26 | } 27 | } 28 | 29 | if (uncachedKeys.length === 0) { 30 | return cached; 31 | } 32 | 33 | // 获取未缓存的数据 34 | const result = await chrome.storage.sync.get(uncachedKeys); 35 | 36 | // 更新缓存 37 | for (const [key, value] of Object.entries(result)) { 38 | this.cache.set(key, value); 39 | } 40 | 41 | return { ...cached, ...result }; 42 | } 43 | 44 | // 获取所有数据 45 | const result = await chrome.storage.sync.get(keys); 46 | 47 | // 更新缓存 48 | for (const [key, value] of Object.entries(result)) { 49 | this.cache.set(key, value); 50 | } 51 | 52 | return result; 53 | } catch (error) { 54 | console.error('获取存储数据失败:', error); 55 | throw new Error('无法获取存储数据'); 56 | } 57 | } 58 | 59 | // 设置存储数据 60 | async set(data) { 61 | try { 62 | await chrome.storage.sync.set(data); 63 | 64 | // 更新缓存 65 | for (const [key, value] of Object.entries(data)) { 66 | this.cache.set(key, value); 67 | } 68 | 69 | // 触发监听器 70 | this.notifyListeners(data); 71 | 72 | return true; 73 | } catch (error) { 74 | console.error('设置存储数据失败:', error); 75 | throw new Error('无法设置存储数据'); 76 | } 77 | } 78 | 79 | // 删除存储数据 80 | async remove(keys) { 81 | try { 82 | if (typeof keys === 'string') { 83 | keys = [keys]; 84 | } 85 | 86 | await chrome.storage.sync.remove(keys); 87 | 88 | // 清除缓存 89 | for (const key of keys) { 90 | this.cache.delete(key); 91 | } 92 | 93 | return true; 94 | } catch (error) { 95 | console.error('删除存储数据失败:', error); 96 | throw new Error('无法删除存储数据'); 97 | } 98 | } 99 | 100 | // 清空所有存储数据 101 | async clear() { 102 | try { 103 | await chrome.storage.sync.clear(); 104 | this.cache.clear(); 105 | return true; 106 | } catch (error) { 107 | console.error('清空存储数据失败:', error); 108 | throw new Error('无法清空存储数据'); 109 | } 110 | } 111 | 112 | // 获取本地存储数据 113 | async getLocal(keys) { 114 | try { 115 | return await chrome.storage.local.get(keys); 116 | } catch (error) { 117 | console.error('获取本地存储数据失败:', error); 118 | throw new Error('无法获取本地存储数据'); 119 | } 120 | } 121 | 122 | // 设置本地存储数据 123 | async setLocal(data) { 124 | try { 125 | await chrome.storage.local.set(data); 126 | return true; 127 | } catch (error) { 128 | console.error('设置本地存储数据失败:', error); 129 | throw new Error('无法设置本地存储数据'); 130 | } 131 | } 132 | 133 | // 删除本地存储数据 134 | async removeLocal(keys) { 135 | try { 136 | if (typeof keys === 'string') { 137 | keys = [keys]; 138 | } 139 | await chrome.storage.local.remove(keys); 140 | return true; 141 | } catch (error) { 142 | console.error('删除本地存储数据失败:', error); 143 | throw new Error('无法删除本地存储数据'); 144 | } 145 | } 146 | 147 | // 获取存储使用情况 148 | async getUsage() { 149 | try { 150 | const syncUsage = await chrome.storage.sync.getBytesInUse(); 151 | const localUsage = await chrome.storage.local.getBytesInUse(); 152 | 153 | return { 154 | sync: { 155 | used: syncUsage, 156 | quota: chrome.storage.sync.QUOTA_BYTES, 157 | percentage: (syncUsage / chrome.storage.sync.QUOTA_BYTES) * 100 158 | }, 159 | local: { 160 | used: localUsage, 161 | quota: chrome.storage.local.QUOTA_BYTES, 162 | percentage: (localUsage / chrome.storage.local.QUOTA_BYTES) * 100 163 | } 164 | }; 165 | } catch (error) { 166 | console.error('获取存储使用情况失败:', error); 167 | throw new Error('无法获取存储使用情况'); 168 | } 169 | } 170 | 171 | // 设置管理 172 | async getSettings() { 173 | const defaultSettings = { 174 | autoBackup: true, 175 | backupPath: '', 176 | autoClassify: true, 177 | classificationRules: [], 178 | aiProvider: 'openai', 179 | aiApiKey: '', 180 | aiApiUrl: '', 181 | searchEnabled: true, 182 | showStats: true, 183 | theme: 'light', 184 | language: 'zh-CN', 185 | backupInterval: 24 * 60 * 60 * 1000, // 24小时 186 | maxBackups: 10, 187 | enableNotifications: true, 188 | debugMode: false 189 | }; 190 | 191 | const settings = await this.get(Object.keys(defaultSettings)); 192 | 193 | // 合并默认设置 194 | const result = {}; 195 | for (const [key, defaultValue] of Object.entries(defaultSettings)) { 196 | result[key] = settings[key] !== undefined ? settings[key] : defaultValue; 197 | } 198 | 199 | return result; 200 | } 201 | 202 | // 更新设置 203 | async updateSettings(updates) { 204 | const currentSettings = await this.getSettings(); 205 | const newSettings = { ...currentSettings, ...updates }; 206 | 207 | // 验证设置 208 | this.validateSettings(newSettings); 209 | 210 | await this.set(newSettings); 211 | return newSettings; 212 | } 213 | 214 | // 验证设置 215 | validateSettings(settings) { 216 | // 验证备份间隔 217 | if (settings.backupInterval < 60 * 60 * 1000) { // 最少1小时 218 | throw new Error('备份间隔不能少于1小时'); 219 | } 220 | 221 | // 验证最大备份数量 222 | if (settings.maxBackups < 1 || settings.maxBackups > 50) { 223 | throw new Error('最大备份数量必须在1-50之间'); 224 | } 225 | 226 | // 验证AI提供商 227 | const validProviders = ['openai', 'deepseek', 'custom']; 228 | if (!validProviders.includes(settings.aiProvider)) { 229 | throw new Error('无效的AI提供商'); 230 | } 231 | 232 | // 验证主题 233 | const validThemes = ['light', 'dark', 'auto']; 234 | if (!validThemes.includes(settings.theme)) { 235 | throw new Error('无效的主题设置'); 236 | } 237 | } 238 | 239 | // 备份管理 240 | async createBackup(data, type = 'manual') { 241 | try { 242 | const backup = { 243 | id: this.generateId(), 244 | type: type, // manual, auto, scheduled 245 | timestamp: Date.now(), 246 | data: data, 247 | size: JSON.stringify(data).length, 248 | version: chrome.runtime.getManifest().version 249 | }; 250 | 251 | // 获取现有备份 252 | const { backups = [] } = await this.getLocal(['backups']); 253 | 254 | // 添加新备份 255 | backups.unshift(backup); 256 | 257 | // 限制备份数量 258 | const settings = await this.getSettings(); 259 | const maxBackups = settings.maxBackups || 10; 260 | if (backups.length > maxBackups) { 261 | backups.splice(maxBackups); 262 | } 263 | 264 | // 保存备份列表 265 | await this.setLocal({ backups }); 266 | 267 | // 更新最后备份时间 268 | await this.set({ lastBackupTime: Date.now() }); 269 | 270 | return backup; 271 | } catch (error) { 272 | console.error('创建备份失败:', error); 273 | throw new Error('无法创建备份'); 274 | } 275 | } 276 | 277 | // 获取备份列表 278 | async getBackups() { 279 | try { 280 | const { backups = [] } = await this.getLocal(['backups']); 281 | return backups.sort((a, b) => b.timestamp - a.timestamp); 282 | } catch (error) { 283 | console.error('获取备份列表失败:', error); 284 | throw new Error('无法获取备份列表'); 285 | } 286 | } 287 | 288 | // 删除备份 289 | async deleteBackup(backupId) { 290 | try { 291 | const { backups = [] } = await this.getLocal(['backups']); 292 | const filteredBackups = backups.filter(backup => backup.id !== backupId); 293 | await this.setLocal({ backups: filteredBackups }); 294 | return true; 295 | } catch (error) { 296 | console.error('删除备份失败:', error); 297 | throw new Error('无法删除备份'); 298 | } 299 | } 300 | 301 | // 清理旧备份 302 | async cleanupOldBackups() { 303 | try { 304 | const settings = await this.getSettings(); 305 | const { backups = [] } = await this.getLocal(['backups']); 306 | 307 | const maxAge = 30 * 24 * 60 * 60 * 1000; // 30天 308 | const cutoffTime = Date.now() - maxAge; 309 | 310 | const validBackups = backups.filter(backup => 311 | backup.timestamp > cutoffTime || backup.type === 'manual' 312 | ); 313 | 314 | // 限制数量 315 | const maxBackups = settings.maxBackups || 10; 316 | if (validBackups.length > maxBackups) { 317 | validBackups.splice(maxBackups); 318 | } 319 | 320 | await this.setLocal({ backups: validBackups }); 321 | return validBackups.length; 322 | } catch (error) { 323 | console.error('清理旧备份失败:', error); 324 | throw new Error('无法清理旧备份'); 325 | } 326 | } 327 | 328 | // 数据迁移 329 | async migrateData(fromVersion, toVersion) { 330 | try { 331 | console.log(`数据迁移: ${fromVersion} -> ${toVersion}`); 332 | 333 | // 这里可以添加版本特定的迁移逻辑 334 | if (fromVersion < '1.0.0') { 335 | // 迁移旧版本数据 336 | await this.migrateFromLegacy(); 337 | } 338 | 339 | // 更新版本信息 340 | await this.set({ dataVersion: toVersion }); 341 | 342 | return true; 343 | } catch (error) { 344 | console.error('数据迁移失败:', error); 345 | throw new Error('数据迁移失败'); 346 | } 347 | } 348 | 349 | // 从旧版本迁移 350 | async migrateFromLegacy() { 351 | // 实现具体的迁移逻辑 352 | console.log('执行旧版本数据迁移...'); 353 | } 354 | 355 | // 添加存储监听器 356 | addListener(key, callback) { 357 | if (!this.listeners.has(key)) { 358 | this.listeners.set(key, new Set()); 359 | } 360 | this.listeners.get(key).add(callback); 361 | } 362 | 363 | // 移除存储监听器 364 | removeListener(key, callback) { 365 | if (this.listeners.has(key)) { 366 | this.listeners.get(key).delete(callback); 367 | } 368 | } 369 | 370 | // 通知监听器 371 | notifyListeners(changes) { 372 | for (const [key, value] of Object.entries(changes)) { 373 | if (this.listeners.has(key)) { 374 | for (const callback of this.listeners.get(key)) { 375 | try { 376 | callback(value, key); 377 | } catch (error) { 378 | console.error('监听器回调失败:', error); 379 | } 380 | } 381 | } 382 | } 383 | } 384 | 385 | // 生成唯一ID 386 | generateId() { 387 | return Date.now().toString(36) + Math.random().toString(36).substr(2); 388 | } 389 | 390 | // 导出所有数据 391 | async exportAllData() { 392 | try { 393 | const syncData = await chrome.storage.sync.get(); 394 | const localData = await chrome.storage.local.get(); 395 | 396 | return { 397 | version: chrome.runtime.getManifest().version, 398 | timestamp: new Date().toISOString(), 399 | sync: syncData, 400 | local: localData 401 | }; 402 | } catch (error) { 403 | console.error('导出数据失败:', error); 404 | throw new Error('无法导出数据'); 405 | } 406 | } 407 | 408 | // 导入数据 409 | async importData(data) { 410 | try { 411 | if (!data.version || !data.sync) { 412 | throw new Error('无效的数据格式'); 413 | } 414 | 415 | // 清空现有数据 416 | await this.clear(); 417 | await chrome.storage.local.clear(); 418 | 419 | // 导入同步数据 420 | if (data.sync && Object.keys(data.sync).length > 0) { 421 | await chrome.storage.sync.set(data.sync); 422 | } 423 | 424 | // 导入本地数据 425 | if (data.local && Object.keys(data.local).length > 0) { 426 | await chrome.storage.local.set(data.local); 427 | } 428 | 429 | // 清除缓存 430 | this.cache.clear(); 431 | 432 | return true; 433 | } catch (error) { 434 | console.error('导入数据失败:', error); 435 | throw new Error('无法导入数据'); 436 | } 437 | } 438 | 439 | // 获取存储统计信息 440 | async getStorageStats() { 441 | try { 442 | const usage = await this.getUsage(); 443 | const backups = await this.getBackups(); 444 | const settings = await this.getSettings(); 445 | 446 | return { 447 | usage: usage, 448 | backupCount: backups.length, 449 | lastBackupTime: settings.lastBackupTime, 450 | totalSize: usage.sync.used + usage.local.used, 451 | cacheSize: this.cache.size 452 | }; 453 | } catch (error) { 454 | console.error('获取存储统计失败:', error); 455 | throw new Error('无法获取存储统计'); 456 | } 457 | } 458 | 459 | // 清除缓存 460 | clearCache() { 461 | this.cache.clear(); 462 | } 463 | } 464 | 465 | // 导出单例实例 466 | const storageService = new StorageService(); 467 | 468 | // 监听存储变化 469 | if (typeof chrome !== 'undefined' && chrome.storage) { 470 | chrome.storage.onChanged.addListener((changes, areaName) => { 471 | console.log('存储变化:', changes, areaName); 472 | 473 | // 更新缓存 474 | if (areaName === 'sync') { 475 | for (const [key, change] of Object.entries(changes)) { 476 | if (change.newValue !== undefined) { 477 | storageService.cache.set(key, change.newValue); 478 | } else { 479 | storageService.cache.delete(key); 480 | } 481 | } 482 | } 483 | 484 | // 通知监听器 485 | const changedData = {}; 486 | for (const [key, change] of Object.entries(changes)) { 487 | changedData[key] = change.newValue; 488 | } 489 | storageService.notifyListeners(changedData); 490 | }); 491 | } 492 | 493 | export default storageService; -------------------------------------------------------------------------------- /src/pages/newtab/index.css: -------------------------------------------------------------------------------- 1 | /* 现代、干净风格,暗色/浅色自适应 */ 2 | :root { 3 | --bg: #0f1115; 4 | --fg: #e6e8ea; 5 | --muted: #9aa0a6; 6 | --card: #151922; 7 | --border: #232838; 8 | --accent: #4f8cff; 9 | --accent-contrast: #ffffff; 10 | --shadow: 0 8px 24px rgba(0,0,0,0.25); 11 | /* 玻璃拟态(暗色,继续下调) */ 12 | --glass-bg: rgba(0,0,0,0.12); 13 | --glass-border: rgba(255,255,255,0.08); 14 | --glass-shadow: 0 12px 30px rgba(0,0,0,0.25); 15 | /* 非聚焦时透明度(分离:搜索框、书签列表) */ 16 | --search-unfocused-opacity: 0.86; 17 | --bookmarks-unfocused-opacity: 0.86; 18 | /* 非聚焦时透明度(新增:60s栏目) */ 19 | --sixty-unfocused-opacity: 0.86; 20 | /* 非聚焦时透明度(新增:热门栏目) */ 21 | --top-visited-unfocused-opacity: 0.86; 22 | } 23 | 24 | @media (prefers-color-scheme: light) { 25 | :root { 26 | --bg: #f7f8fa; 27 | --fg: #374151; /* 更柔和的浅色主题前景色 */ 28 | --muted: #6b7280; 29 | --card: #ffffff; 30 | --border: #e5e7eb; 31 | --accent: #2563eb; 32 | --accent-contrast: #ffffff; 33 | --shadow: 0 8px 24px rgba(0,0,0,0.08); 34 | /* 玻璃拟态(浅色,继续下调) */ 35 | --glass-bg: rgba(0,0,0,0.08); 36 | --glass-border: rgba(255,255,255,0.06); 37 | --glass-shadow: 0 10px 24px rgba(0,0,0,0.08); 38 | } 39 | } 40 | 41 | /* 手动覆盖主题(优先级高于系统) */ 42 | :root[data-theme="dark"] { 43 | --bg: #0f1115; 44 | --fg: #e6e8ea; 45 | --muted: #9aa0a6; 46 | --card: #151922; 47 | --border: #232838; 48 | --accent: #4f8cff; 49 | --accent-contrast: #ffffff; 50 | --shadow: 0 8px 24px rgba(0,0,0,0.25); 51 | } 52 | 53 | :root[data-theme="light"] { 54 | --bg: #f7f8fa; 55 | --fg: #374151; /* 更柔和的浅色主题前景色 */ 56 | --muted: #6b7280; 57 | --card: #ffffff; 58 | --border: #e5e7eb; 59 | --accent: #2563eb; 60 | --accent-contrast: #ffffff; 61 | --shadow: 0 8px 24px rgba(0,0,0,0.08); 62 | --glass-bg: rgba(0,0,0,0.08); 63 | --glass-border: rgba(255,255,255,0.06); 64 | --glass-shadow: 0 10px 24px rgba(0,0,0,0.08); 65 | } 66 | 67 | * { box-sizing: border-box; } 68 | html, body { height: 100%; } 69 | body { 70 | margin: 0; 71 | background: var(--bg); 72 | color: var(--fg); 73 | font: 14px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Microsoft YaHei', sans-serif; 74 | overflow-x: hidden; /* 防止横向滚动 */ 75 | min-height: 100vh; /* 保证视口高度铺满 */ 76 | background-size: cover; 77 | background-position: center; 78 | background-repeat: no-repeat; 79 | background-attachment: fixed; 80 | } 81 | 82 | /* 统一支持 HTML hidden 属性,不被组件 display 覆盖 */ 83 | [hidden] { display: none !important; } 84 | 85 | /* 小屏与移动端禁用 fixed,避免滚动时出现“变形/抖动” */ 86 | @media (max-width: 768px) { 87 | body { 88 | background-attachment: scroll; 89 | background-size: cover; 90 | background-position: center; 91 | } 92 | } 93 | 94 | .page { 95 | width: 100%; 96 | max-width: 80%; 97 | margin: 0 auto; 98 | padding: 32px 24px 24px; 99 | min-width: 0; 100 | position: relative; /* 让右上角工具条基于页面定位 */ 101 | z-index: 1; /* 置于壁纸暗层之上 */ 102 | } 103 | 104 | .header { text-align: center; margin-bottom: 28px; position: relative; } 105 | .header-content { display: inline-block; position: relative; } 106 | .time { 107 | font-size: 56px; 108 | font-weight: 700; 109 | letter-spacing: 2px; 110 | } 111 | .subtitle { color: var(--muted); margin-top: 8px; } 112 | .time { text-shadow: 0 1px 2px rgba(0,0,0,0.6); } 113 | .subtitle { text-shadow: 0 1px 2px rgba(0,0,0,0.5); } 114 | /* 外部搜索提示已移除,保留输入框占位符提示 */ 115 | .topbar { position: absolute; top: 12px; right: 24px; z-index: 2; } 116 | .theme-dropdown { position: relative; } 117 | .dropdown-menu { 118 | position: absolute; 119 | right: 0; 120 | top: calc(100% + 6px); 121 | min-width: 140px; 122 | background: var(--card); 123 | border: 1px solid var(--border); 124 | border-radius: 10px; 125 | box-shadow: var(--shadow); 126 | padding: 6px; 127 | } 128 | .dropdown-item { 129 | width: 100%; 130 | text-align: left; 131 | padding: 8px 10px; 132 | border: none; 133 | background: transparent; 134 | color: var(--fg); 135 | border-radius: 8px; 136 | cursor: pointer; 137 | } 138 | .dropdown-item:hover { background: rgba(255,255,255,0.06); } 139 | .theme-toggle { display: flex; gap: 8px; align-items: center; } 140 | .theme-btn { 141 | display: inline-flex; 142 | align-items: center; 143 | justify-content: center; 144 | width: 34px; height: 34px; 145 | border-radius: 10px; 146 | border: 1px solid var(--border); 147 | background: var(--card); 148 | color: var(--fg); 149 | cursor: pointer; 150 | } 151 | .theme-btn:hover { filter: brightness(1.05); } 152 | .theme-btn.active { outline: 2px solid var(--accent); } 153 | .header-controls { display: none; } 154 | .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; } 155 | .theme-select { 156 | appearance: none; 157 | border: 1px solid var(--border); 158 | border-radius: 10px; 159 | background: transparent; 160 | color: var(--fg); 161 | padding: 8px 12px; 162 | min-width: 140px; 163 | } 164 | 165 | .main { display: grid; gap: 24px; min-width: 0; } 166 | 167 | 168 | /* 外部搜索提示已移除 */ 169 | 170 | /* 天气摘要条 */ 171 | .weather-inline { 172 | display: inline-flex; 173 | align-items: baseline; 174 | gap: 8px; 175 | margin-left: 8px; /* 与副标题保持间距 */ 176 | color: var(--fg); 177 | cursor: pointer; /* 可点击指示 */ 178 | text-shadow: 0 1px 2px rgba(0,0,0,0.5); 179 | } 180 | .weather-icon { 181 | width: 20px; 182 | height: 20px; 183 | } 184 | .weather-main { 185 | display: inline-flex; 186 | align-items: baseline; 187 | gap: 8px; 188 | min-width: 0; 189 | } 190 | .weather-city { 191 | font-weight: 600; 192 | } 193 | .weather-temp { 194 | font-weight: 600; 195 | } 196 | .weather-desc { 197 | color: var(--muted); 198 | font-size: 12px; 199 | overflow: hidden; 200 | text-overflow: ellipsis; 201 | white-space: nowrap; 202 | } 203 | .weather-refresh { 204 | appearance: none; 205 | border: none; 206 | background: transparent; 207 | color: var(--muted); 208 | cursor: pointer; 209 | font-size: 12px; 210 | } 211 | .weather-refresh:hover { color: var(--accent); } 212 | 213 | .search { 214 | display: grid; 215 | grid-template-columns: 1fr auto; /* 输入 + 按钮 */ 216 | gap: 8px; 217 | background: var(--card); 218 | border: 1px solid var(--border); 219 | border-radius: 14px; 220 | padding: 12px; 221 | box-shadow: var(--shadow); 222 | transition: opacity 160ms ease; 223 | } 224 | 225 | /* 非聚焦态统一调暗(不影响交互) */ 226 | .search:not(:hover):not(:focus-within) { opacity: var(--search-unfocused-opacity); } 227 | .bookmarks { transition: opacity 160ms ease; } 228 | .bookmarks:not(:hover) { opacity: var(--bookmarks-unfocused-opacity); } 229 | 230 | /* 60s 栏目非悬停时的透明度(可配置) */ 231 | #sixty-seconds { transition: opacity 160ms ease; } 232 | #sixty-seconds:not(:hover) { opacity: var(--sixty-unfocused-opacity); } 233 | /* 热门栏目透明度控制 */ 234 | #top-visited:not(:hover) { opacity: var(--top-visited-unfocused-opacity); } 235 | .search input { 236 | appearance: none; 237 | border: none; 238 | outline: none; 239 | background: transparent; 240 | color: var(--fg); 241 | font-size: 16px; 242 | padding: 10px 12px; 243 | } 244 | .search input::placeholder { color: var(--muted); } 245 | .search button { 246 | appearance: none; 247 | border: 1px solid var(--border); 248 | cursor: pointer; 249 | background: var(--card); 250 | color: var(--fg); 251 | height: 36px; 252 | width: 36px; /* 改为方形按钮承载图标 */ 253 | padding: 0; /* 去除文字内边距 */ 254 | border-radius: 999px; 255 | font-weight: 600; 256 | display: inline-flex; /* 居中图标 */ 257 | align-items: center; 258 | justify-content: center; 259 | } 260 | .search button:hover { filter: brightness(1.05); border-color: var(--accent); } 261 | .search button:focus { outline: none; box-shadow: 0 0 0 2px rgba(79,140,255,0.24); } 262 | /* 图标显示优化 */ 263 | .search button svg { display: block; } 264 | 265 | /* 副标题中的 60s 提示(与天气并排) */ 266 | /* 副标题主文案可被 60s 提示替换 */ 267 | #subtitle-main { display: block; } 268 | 269 | /* 60s 读懂世界样式 */ 270 | .sixty-body { 271 | display: grid; 272 | grid-template-columns: 200px 1fr; /* 封面更大 + 内容 */ 273 | gap: 14px; 274 | padding: 14px 16px; 275 | } 276 | 277 | /* 整体作为链接时的统一悬浮效果 */ 278 | .sixty-body.is-link { cursor: pointer; } 279 | .sixty-body.is-link:hover { filter: brightness(1.05); } 280 | .sixty-body.is-link:hover .sixty-news span:nth-child(2) { text-decoration: underline; } 281 | .sixty-cover { 282 | width: 100%; 283 | height: 140px; 284 | border-radius: 10px; 285 | object-fit: cover; 286 | border: 1px solid var(--border); 287 | box-shadow: var(--shadow); 288 | } 289 | .sixty-content { display: grid; gap: 8px; } 290 | .sixty-meta { color: var(--muted); font-size: 12px; } 291 | .sixty-tip { color: var(--muted); font-size: 12px; } 292 | .sixty-news { 293 | list-style: none; 294 | margin: 0; 295 | padding: 0; 296 | display: grid; 297 | gap: 6px; 298 | } 299 | .sixty-news-link { 300 | display: grid; 301 | grid-template-columns: 10px 1fr; 302 | gap: 8px; 303 | align-items: start; 304 | text-decoration: none; 305 | color: inherit; 306 | } 307 | .sixty-news-link:hover { filter: brightness(1.08); } 308 | .sixty-bullet { 309 | width: 6px; 310 | height: 6px; 311 | border-radius: 50%; 312 | background: var(--accent); 313 | margin-top: 7px; 314 | } 315 | .sixty-link { color: var(--accent); text-decoration: none; font-weight: 600; } 316 | 317 | .section-head-right { display: flex; align-items: center; gap: 10px; } 318 | /* 原文按钮已移除,保留日期右对齐容器 */ 319 | 320 | @media (max-width: 900px) { 321 | .sixty-body { grid-template-columns: 1fr; } 322 | .sixty-cover { height: 160px; } 323 | } 324 | 325 | .bookmarks { display: grid; gap: 16px; grid-template-columns: 1fr 1fr; width: 100%; min-width: 0; } 326 | @media (max-width: 900px) { .bookmarks { grid-template-columns: 1fr; } } 327 | 328 | .bookmarks-placeholder { 329 | display: block; 330 | color: var(--muted); 331 | font-size: 13px; 332 | margin: 6px 0 0; 333 | text-align: center; 334 | } 335 | 336 | .section { 337 | background: var(--card); 338 | border: 1px solid var(--border); 339 | border-radius: 14px; 340 | box-shadow: var(--shadow); 341 | max-width: 100%; 342 | min-width: 0; /* 允许内容在网格中收缩,避免撑宽 */ 343 | transition: box-shadow 120ms ease, opacity 120ms ease; 344 | position: relative; /* 为明确的上下插入条提供定位上下文 */ 345 | } 346 | .section.dragging { opacity: 0.6; } 347 | .section.drop-before { box-shadow: 0 0 0 1px rgba(79,140,255,0.18); } 348 | .section.drop-after { box-shadow: 0 0 0 1px rgba(79,140,255,0.18); } 349 | 350 | /* 更明确的落位提示:顶部/底部插入条 */ 351 | .section.drop-before::before { 352 | content: ""; 353 | position: absolute; 354 | left: 0; 355 | right: 0; 356 | top: -2px; 357 | height: 6px; 358 | border-radius: 6px 6px 0 0; 359 | background: var(--accent); 360 | box-shadow: 0 2px 6px rgba(0,0,0,0.08); 361 | } 362 | .section.drop-after::after { 363 | content: ""; 364 | position: absolute; 365 | left: 0; 366 | right: 0; 367 | bottom: -2px; 368 | height: 6px; 369 | border-radius: 0 0 6px 6px; 370 | background: var(--accent); 371 | box-shadow: 0 -2px 6px rgba(0,0,0,0.08); 372 | } 373 | .section-header { 374 | display: flex; 375 | align-items: center; 376 | justify-content: space-between; 377 | padding: 12px 16px; 378 | border-bottom: 1px solid var(--border); 379 | cursor: grab; 380 | } 381 | .section-header:active { cursor: grabbing; } 382 | .section-head-left { display: flex; align-items: center; gap: 8px; } 383 | .drag-handle { 384 | width: 24px; height: 24px; 385 | display: inline-flex; 386 | align-items: center; justify-content: center; 387 | border: 1px solid var(--border); 388 | border-radius: 6px; 389 | background: var(--card); 390 | color: var(--muted); 391 | cursor: grab; 392 | } 393 | .drag-handle:hover { filter: brightness(1.05); color: var(--fg); } 394 | .drag-handle:active { cursor: grabbing; } 395 | .section-title { font-weight: 600; } 396 | .section-count { color: var(--muted); font-size: 12px; } 397 | 398 | /* 拖拽过程中防止误选中文本 */ 399 | body.drag-active { user-select: none; } 400 | 401 | .list { 402 | list-style: none; 403 | margin: 0; 404 | padding: 8px; 405 | min-width: 0; 406 | max-height: clamp(240px, 40vh, 420px); 407 | overflow-y: auto; 408 | scrollbar-width: thin; 409 | scrollbar-color: var(--border) transparent; 410 | } 411 | .list::-webkit-scrollbar { width: 8px; } 412 | .list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; } 413 | .list::-webkit-scrollbar-thumb:hover { background: rgba(79,140,255,0.35); } 414 | .list::-webkit-scrollbar-track { background: transparent; } 415 | .item { 416 | display: grid; 417 | grid-template-columns: 20px minmax(0, 1fr) auto; /* 允许中间列收缩 */ 418 | align-items: center; 419 | gap: 12px; 420 | padding: 8px 10px; 421 | border-radius: 10px; 422 | min-width: 0; /* 修复网格项溢出 */ 423 | text-decoration: none; /* 当 .item 为链接时去掉下划线 */ 424 | color: inherit; /* 链接文本颜色继承 */ 425 | } 426 | .item:hover { background: rgba(255,255,255,0.04); } 427 | .bullet { 428 | width: 8px; 429 | height: 8px; 430 | border-radius: 50%; 431 | background: var(--accent); 432 | margin-left: 6px; 433 | } 434 | .item-main { min-width: 0; } 435 | 436 | .title { 437 | overflow: hidden; 438 | min-width: 0; 439 | display: -webkit-box; 440 | -webkit-line-clamp: 2; /* 多行省略 */ 441 | -webkit-box-orient: vertical; 442 | word-break: break-word; 443 | overflow-wrap: anywhere; 444 | } 445 | .url { color: var(--muted); font-size: 12px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; min-width: 0; } 446 | .open-btn { color: var(--accent); text-decoration: none; font-weight: 600; white-space: nowrap; } 447 | 448 | .footer { 449 | display: flex; 450 | align-items: center; 451 | justify-content: center; 452 | margin-top: 12px; 453 | color: var(--muted); 454 | } 455 | .brand { display: flex; align-items: center; gap: 8px; } 456 | .brand img { width: 18px; height: 18px; } 457 | 458 | /* 移除暗层,仅保留文字阴影以增强可读性 */ 459 | .header-content::before { 460 | content: ""; 461 | position: absolute; 462 | left: -8px; 463 | right: -8px; 464 | transform: none; 465 | top: -8px; 466 | /* 去掉固定宽度,使用左右边距来自适应扩展 */ 467 | height: calc(100% + 16px); 468 | border-radius: 16px; 469 | z-index: -1; /* 放在内容之后,不遮挡文字 */ 470 | pointer-events: none; /* 不影响点击等交互 */ 471 | display: none; /* 默认不显示,仅壁纸时显示 */ 472 | } 473 | 474 | /* 有壁纸时给 Header 加一层轻薄玻璃拟态背景(不覆盖整页) */ 475 | body.has-wallpaper .header-content::before { 476 | display: block; 477 | background: var(--glass-bg); 478 | backdrop-filter: blur(6px); 479 | -webkit-backdrop-filter: blur(6px); 480 | border: 1px solid var(--glass-border); 481 | box-shadow: var(--glass-shadow); 482 | } 483 | 484 | /* 明亮主题:有壁纸时,Header 文本改为亮色以提高适配性 */ 485 | @media (prefers-color-scheme: light) { 486 | body.has-wallpaper .header { color: #ffffff; } 487 | /* 覆盖副标题、天气、搜索提示为亮色 */ 488 | body.has-wallpaper .subtitle, 489 | body.has-wallpaper .weather-inline, 490 | body.has-wallpaper .weather-inline .weather-desc, 491 | body.has-wallpaper .weather-refresh { color: rgba(255,255,255,0.9); } 492 | } 493 | 494 | :root[data-theme="light"] body.has-wallpaper .header { color: #ffffff; } 495 | /* 手动明亮主题下同样覆盖为亮色 */ 496 | :root[data-theme="light"] body.has-wallpaper .subtitle, 497 | :root[data-theme="light"] body.has-wallpaper .weather-inline, 498 | :root[data-theme="light"] body.has-wallpaper .weather-inline .weather-desc, 499 | :root[data-theme="light"] body.has-wallpaper .weather-refresh { color: rgba(255,255,255,0.9); } 500 | 501 | /* 通用:有壁纸时提升副标题与天气文字对比度(所有主题) */ 502 | body.has-wallpaper .subtitle, 503 | body.has-wallpaper #subtitle-main, 504 | body.has-wallpaper .weather-inline, 505 | body.has-wallpaper .weather-inline .weather-desc, 506 | body.has-wallpaper .weather-refresh { 507 | color: rgba(255,255,255,0.92); 508 | text-shadow: 0 1px 2px rgba(0,0,0,0.6); 509 | } -------------------------------------------------------------------------------- /services/cloudSyncService.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 通用云同步服务 3 | * 支持多种云存储提供商:GitHub、WebDAV、Google Drive 4 | */ 5 | 6 | class CloudSyncService { 7 | constructor() { 8 | this.providers = { 9 | github: new GitHubSyncProvider(), 10 | webdav: new WebDAVSyncProvider(), 11 | googledrive: new GoogleDriveSyncProvider() 12 | }; 13 | } 14 | 15 | /** 16 | * 同步备份到指定的云提供商 17 | * @param {string} provider - 提供商类型 (github/webdav/googledrive) 18 | * @param {Object} config - 提供商配置 19 | * @param {Object} data - 要同步的数据 20 | * @returns {Promise} 同步结果 21 | */ 22 | async syncBackup(provider, config, data) { 23 | const syncProvider = this.providers[provider]; 24 | if (!syncProvider) { 25 | throw new Error(`不支持的云提供商: ${provider}`); 26 | } 27 | 28 | try { 29 | // 验证配置 30 | await syncProvider.validateConfig(config); 31 | 32 | // 执行同步 33 | const result = await syncProvider.uploadBackup(config, data); 34 | 35 | return { 36 | success: true, 37 | provider, 38 | data: result 39 | }; 40 | } catch (error) { 41 | return { 42 | success: false, 43 | provider, 44 | error: error.message 45 | }; 46 | } 47 | } 48 | 49 | /** 50 | * 测试连接 51 | * @param {string} provider - 提供商类型 52 | * @param {Object} config - 提供商配置 53 | * @returns {Promise} 测试结果 54 | */ 55 | async testConnection(provider, config) { 56 | const syncProvider = this.providers[provider]; 57 | if (!syncProvider) { 58 | throw new Error(`不支持的云提供商: ${provider}`); 59 | } 60 | 61 | try { 62 | await syncProvider.testConnection(config); 63 | return { success: true, message: '连接测试成功' }; 64 | } catch (error) { 65 | return { success: false, error: error.message }; 66 | } 67 | } 68 | 69 | /** 70 | * 获取支持的提供商列表 71 | * @returns {Array} 提供商列表 72 | */ 73 | getSupportedProviders() { 74 | return Object.keys(this.providers); 75 | } 76 | } 77 | 78 | /** 79 | * 云同步提供商基类 80 | */ 81 | class BaseSyncProvider { 82 | /** 83 | * 验证配置 84 | * @param {Object} config - 配置对象 85 | * @throws {Error} 配置无效时抛出错误 86 | */ 87 | async validateConfig(config) { 88 | throw new Error('子类必须实现 validateConfig 方法'); 89 | } 90 | 91 | /** 92 | * 测试连接 93 | * @param {Object} config - 配置对象 94 | * @throws {Error} 连接失败时抛出错误 95 | */ 96 | async testConnection(config) { 97 | throw new Error('子类必须实现 testConnection 方法'); 98 | } 99 | 100 | /** 101 | * 上传备份 102 | * @param {Object} config - 配置对象 103 | * @param {Object} data - 备份数据 104 | * @returns {Promise} 上传结果 105 | */ 106 | async uploadBackup(config, data) { 107 | throw new Error('子类必须实现 uploadBackup 方法'); 108 | } 109 | 110 | /** 111 | * Base64 编码工具(兼容中文) 112 | * @param {string} str - 要编码的字符串 113 | * @returns {string} Base64 编码结果 114 | */ 115 | toBase64(str) { 116 | try { 117 | return btoa(unescape(encodeURIComponent(str))); 118 | } catch (_) { 119 | const bytes = new TextEncoder().encode(str); 120 | let binary = ''; 121 | bytes.forEach(b => { binary += String.fromCharCode(b); }); 122 | return btoa(binary); 123 | } 124 | } 125 | 126 | /** 127 | * 生成 Chrome 兼容的书签 HTML 128 | * @param {Array} bookmarkTree - 书签树 129 | * @returns {string} HTML 字符串 130 | */ 131 | generateBookmarkHTML(bookmarkTree) { 132 | const escapeHtml = (text = '') => String(text) 133 | .replace(/&/g, '&') 134 | .replace(//g, '>') 136 | .replace(/"/g, '"') 137 | .replace(/'/g, '''); 138 | 139 | const processBookmarkNode = (node, depth, defaultTimestamp) => { 140 | const indent = ' '.repeat(depth); 141 | let html = ''; 142 | if (node.children) { 143 | const addDate = node.dateAdded ? Math.floor(node.dateAdded / 1000) : defaultTimestamp; 144 | const lastModified = node.dateGroupModified ? Math.floor(node.dateGroupModified / 1000) : defaultTimestamp; 145 | html += `${indent}

      ${escapeHtml(node.title || '未命名文件夹')}

      \n`; 146 | html += `${indent}

      \n`; 147 | for (const child of node.children) { 148 | html += processBookmarkNode(child, depth + 1, defaultTimestamp); 149 | } 150 | html += `${indent}

      \n`; 151 | } else if (node.url) { 152 | const addDate = node.dateAdded ? Math.floor(node.dateAdded / 1000) : defaultTimestamp; 153 | const icon = node.icon || ''; 154 | html += `${indent}

      ${escapeHtml(node.title || node.url)}\n`; 157 | } 158 | return html; 159 | }; 160 | 161 | const timestamp = Math.floor(Date.now() / 1000); 162 | let html = `\n\n\nBookmarks\n

      Bookmarks

      \n\n

      \n`; 163 | 164 | if (bookmarkTree && bookmarkTree.length > 0) { 165 | const rootNode = bookmarkTree[0]; 166 | if (rootNode.children) { 167 | for (const child of rootNode.children) { 168 | html += processBookmarkNode(child, 1, timestamp); 169 | } 170 | } 171 | } 172 | html += `

      \n`; 173 | return html; 174 | } 175 | } 176 | 177 | /** 178 | * GitHub 同步提供商 179 | */ 180 | class GitHubSyncProvider extends BaseSyncProvider { 181 | async validateConfig(config) { 182 | const { token, owner, repo } = config; 183 | if (!token || !owner || !repo) { 184 | throw new Error('GitHub 配置不完整:需要 token、owner 和 repo'); 185 | } 186 | } 187 | 188 | async testConnection(config) { 189 | const { token, owner, repo } = config; 190 | const response = await fetch(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { 191 | headers: { 192 | 'Authorization': `token ${token}`, 193 | 'Accept': 'application/vnd.github.v3+json' 194 | } 195 | }); 196 | 197 | if (!response.ok) { 198 | const errorText = await response.text(); 199 | throw new Error(`GitHub 连接失败 (${response.status}): ${errorText}`); 200 | } 201 | } 202 | 203 | async uploadBackup(config, data) { 204 | const { token, owner, repo, format = 'json', dualUpload = false } = config; 205 | 206 | // 获取仓库默认分支 207 | let branch = 'main'; 208 | try { 209 | const repoInfoRes = await fetch(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { 210 | headers: { 211 | 'Authorization': `token ${token}`, 212 | 'Accept': 'application/vnd.github.v3+json' 213 | } 214 | }); 215 | if (repoInfoRes.ok) { 216 | const info = await repoInfoRes.json(); 217 | if (info && typeof info.default_branch === 'string' && info.default_branch.trim()) { 218 | branch = info.default_branch.trim(); 219 | } 220 | } 221 | } catch (e) { 222 | console.warn('获取仓库默认分支失败,使用 main 作为默认', e); 223 | } 224 | 225 | const path = 'tidymark/backups/tidymark-backup.json'; 226 | const pathHtml = 'tidymark/backups/tidymark-bookmarks.html'; 227 | 228 | const uploadOne = async (filePath, contentStr) => { 229 | const segs = String(filePath).split('/').map(s => encodeURIComponent(s)).join('/'); 230 | const baseUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${segs}`; 231 | const headers = { 232 | 'Authorization': `token ${token}`, 233 | 'Accept': 'application/vnd.github.v3+json', 234 | 'Content-Type': 'application/json' 235 | }; 236 | 237 | // 获取现有文件的 SHA 238 | let sha; 239 | try { 240 | const getRes = await fetch(`${baseUrl}?ref=${encodeURIComponent(branch)}`, { headers }); 241 | if (getRes.status === 200) { 242 | const data = await getRes.json(); 243 | sha = data && data.sha; 244 | } 245 | } catch (e) { 246 | console.warn('检查现有文件失败(忽略)', e); 247 | } 248 | 249 | const body = { 250 | message: `TidyMark backup: ${new Date().toISOString()}`, 251 | content: this.toBase64(contentStr), 252 | branch 253 | }; 254 | if (sha) body.sha = sha; 255 | 256 | const putRes = await fetch(baseUrl, { 257 | method: 'PUT', 258 | headers, 259 | body: JSON.stringify(body) 260 | }); 261 | 262 | if (putRes.status === 201 || putRes.status === 200) { 263 | const data = await putRes.json(); 264 | return { success: true, data }; 265 | } 266 | 267 | const errText = await putRes.text(); 268 | throw new Error(`GitHub 上传失败 (${putRes.status}): ${errText}`); 269 | }; 270 | 271 | const results = []; 272 | if (dualUpload || format === 'json') { 273 | results.push(await uploadOne(path, JSON.stringify(data, null, 2))); 274 | } 275 | if (dualUpload || format === 'html') { 276 | const htmlStr = this.generateBookmarkHTML(data.bookmarks || []); 277 | results.push(await uploadOne(pathHtml, htmlStr)); 278 | } 279 | 280 | const last = results[results.length - 1]; 281 | return { 282 | contentPath: dualUpload ? `${path} & ${pathHtml}` : (format === 'json' ? path : pathHtml), 283 | htmlUrl: (last && last.data && last.data.content && last.data.content.html_url) || 284 | (last && last.data && last.data.commit && last.data.commit.html_url) || null 285 | }; 286 | } 287 | } 288 | 289 | /** 290 | * WebDAV 同步提供商 291 | */ 292 | class WebDAVSyncProvider extends BaseSyncProvider { 293 | async validateConfig(config) { 294 | const { baseUrl, username, password, targetPath } = config; 295 | if (!baseUrl || !username || !password) { 296 | throw new Error('WebDAV 配置不完整:需要基地址、用户名和密码'); 297 | } 298 | } 299 | 300 | async testConnection(config) { 301 | const { baseUrl, username, password } = config; 302 | const testUrl = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'; 303 | 304 | const response = await fetch(testUrl, { 305 | method: 'PROPFIND', 306 | headers: { 307 | 'Authorization': `Basic ${btoa(`${username}:${password}`)}`, 308 | 'Depth': '0', 309 | 'Content-Type': 'application/xml' 310 | }, 311 | body: '' 312 | }); 313 | 314 | if (!response.ok) { 315 | throw new Error(`WebDAV 连接失败 (${response.status}): ${response.statusText}`); 316 | } 317 | } 318 | 319 | async uploadBackup(config, data) { 320 | const { baseUrl, username, password, targetPath = 'tidymark', format = 'json', dualUpload = false } = config; 321 | const auth = `Basic ${this.toBase64(`${username}:${password}`)}`; 322 | 323 | // 确保目标路径存在(规范化去掉开头结尾斜杠) 324 | const normalizedPath = String(targetPath || 'tidymark').replace(/^\/+/, '').replace(/\/+$/, ''); 325 | await this.ensureDirectory(baseUrl, normalizedPath, auth); 326 | 327 | const uploadOne = async (fileName, content, contentType = 'application/json') => { 328 | const fileUrl = `${baseUrl.replace(/\/$/, '')}/${normalizedPath}/${fileName}`; 329 | 330 | const response = await fetch(fileUrl, { 331 | method: 'PUT', 332 | headers: { 333 | 'Authorization': auth, 334 | 'Content-Type': contentType 335 | }, 336 | body: content 337 | }); 338 | 339 | if (!response.ok) { 340 | throw new Error(`WebDAV 上传失败 (${response.status}): ${response.statusText}`); 341 | } 342 | 343 | return { success: true, url: fileUrl }; 344 | }; 345 | 346 | const results = []; 347 | if (dualUpload || format === 'json') { 348 | results.push(await uploadOne('tidymark-backup.json', JSON.stringify(data, null, 2))); 349 | } 350 | if (dualUpload || format === 'html') { 351 | const htmlStr = this.generateBookmarkHTML(data.bookmarks || []); 352 | results.push(await uploadOne('tidymark-bookmarks.html', htmlStr, 'text/html')); 353 | } 354 | 355 | return { 356 | contentPath: dualUpload ? 'tidymark-backup.json & tidymark-bookmarks.html' : 357 | (format === 'json' ? 'tidymark-backup.json' : 'tidymark-bookmarks.html'), 358 | uploadedFiles: results.map(r => r.url) 359 | }; 360 | } 361 | 362 | async ensureDirectory(baseUrl, path, auth) { 363 | const dirUrl = `${baseUrl.replace(/\/$/, '')}/${path}/`; 364 | 365 | try { 366 | // 检查目录是否存在 367 | const checkResponse = await fetch(dirUrl, { 368 | method: 'PROPFIND', 369 | headers: { 370 | 'Authorization': auth, 371 | 'Depth': '0', 372 | 'Content-Type': 'application/xml' 373 | }, 374 | body: '' 375 | }); 376 | 377 | if (checkResponse.status === 404) { 378 | // 目录不存在,创建它 379 | const createResponse = await fetch(dirUrl, { 380 | method: 'MKCOL', 381 | headers: { 382 | 'Authorization': auth 383 | } 384 | }); 385 | 386 | if (!createResponse.ok && createResponse.status !== 405) { // 405 表示目录已存在 387 | throw new Error(`创建目录失败 (${createResponse.status}): ${createResponse.statusText}`); 388 | } 389 | } 390 | } catch (error) { 391 | console.warn('检查/创建目录时出错:', error); 392 | } 393 | } 394 | } 395 | 396 | /** 397 | * Google Drive 同步提供商 398 | */ 399 | class GoogleDriveSyncProvider extends BaseSyncProvider { 400 | async validateConfig(config) { 401 | const { accessToken } = config; 402 | if (!accessToken) { 403 | throw new Error('Google Drive 配置不完整:需要访问令牌'); 404 | } 405 | } 406 | 407 | async testConnection(config) { 408 | const { accessToken } = config; 409 | 410 | const response = await fetch('https://www.googleapis.com/drive/v3/about?fields=user', { 411 | headers: { 412 | 'Authorization': `Bearer ${accessToken}`, 413 | 'Content-Type': 'application/json' 414 | } 415 | }); 416 | 417 | if (!response.ok) { 418 | const errorText = await response.text(); 419 | throw new Error(`Google Drive 连接失败 (${response.status}): ${errorText}`); 420 | } 421 | } 422 | 423 | async uploadBackup(config, data) { 424 | const { accessToken, folderId, format = 'json', dualUpload = false } = config; 425 | 426 | const uploadOne = async (fileName, content, mimeType = 'application/json') => { 427 | // 检查文件是否已存在 428 | let fileId = null; 429 | const searchQuery = folderId ? 430 | `name='${fileName}' and '${folderId}' in parents and trashed=false` : 431 | `name='${fileName}' and trashed=false`; 432 | 433 | const searchResponse = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(searchQuery)}`, { 434 | headers: { 435 | 'Authorization': `Bearer ${accessToken}` 436 | } 437 | }); 438 | 439 | if (searchResponse.ok) { 440 | const searchResult = await searchResponse.json(); 441 | if (searchResult.files && searchResult.files.length > 0) { 442 | fileId = searchResult.files[0].id; 443 | } 444 | } 445 | 446 | const metadata = { 447 | name: fileName, 448 | ...(folderId && !fileId && { parents: [folderId] }) 449 | }; 450 | 451 | const form = new FormData(); 452 | form.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' })); 453 | form.append('media', new Blob([content], { type: mimeType })); 454 | 455 | const url = fileId ? 456 | `https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=multipart` : 457 | 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'; 458 | 459 | const response = await fetch(url, { 460 | method: fileId ? 'PATCH' : 'POST', 461 | headers: { 462 | 'Authorization': `Bearer ${accessToken}` 463 | }, 464 | body: form 465 | }); 466 | 467 | if (!response.ok) { 468 | const errorText = await response.text(); 469 | throw new Error(`Google Drive 上传失败 (${response.status}): ${errorText}`); 470 | } 471 | 472 | const result = await response.json(); 473 | return { success: true, fileId: result.id, webViewLink: result.webViewLink }; 474 | }; 475 | 476 | const results = []; 477 | if (dualUpload || format === 'json') { 478 | results.push(await uploadOne('tidymark-backup.json', JSON.stringify(data, null, 2))); 479 | } 480 | if (dualUpload || format === 'html') { 481 | const htmlStr = this.generateBookmarkHTML(data.bookmarks || []); 482 | results.push(await uploadOne('tidymark-bookmarks.html', htmlStr, 'text/html')); 483 | } 484 | 485 | return { 486 | contentPath: dualUpload ? 'tidymark-backup.json & tidymark-bookmarks.html' : 487 | (format === 'json' ? 'tidymark-backup.json' : 'tidymark-bookmarks.html'), 488 | uploadedFiles: results.map(r => ({ fileId: r.fileId, webViewLink: r.webViewLink })) 489 | }; 490 | } 491 | } 492 | 493 | // 导出服务实例 494 | const cloudSyncService = new CloudSyncService(); 495 | 496 | // 将服务挂载到全局作用域(适配 background/service worker 环境) 497 | if (typeof globalThis !== 'undefined') { 498 | globalThis.CloudSyncService = cloudSyncService; 499 | } 500 | 501 | // 兼容浏览器环境 502 | if (typeof window !== 'undefined') { 503 | window.CloudSyncService = cloudSyncService; 504 | } 505 | 506 | // 兼容 Node.js 环境 507 | if (typeof module !== 'undefined' && module.exports) { 508 | module.exports = { CloudSyncService, cloudSyncService }; 509 | } -------------------------------------------------------------------------------- /extensions/organize/services/cloudSyncService.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 通用云同步服务 3 | * 支持多种云存储提供商:GitHub、WebDAV、Google Drive 4 | */ 5 | 6 | class CloudSyncService { 7 | constructor() { 8 | this.providers = { 9 | github: new GitHubSyncProvider(), 10 | webdav: new WebDAVSyncProvider(), 11 | googledrive: new GoogleDriveSyncProvider() 12 | }; 13 | } 14 | 15 | /** 16 | * 同步备份到指定的云提供商 17 | * @param {string} provider - 提供商类型 (github/webdav/googledrive) 18 | * @param {Object} config - 提供商配置 19 | * @param {Object} data - 要同步的数据 20 | * @returns {Promise} 同步结果 21 | */ 22 | async syncBackup(provider, config, data) { 23 | const syncProvider = this.providers[provider]; 24 | if (!syncProvider) { 25 | throw new Error(`不支持的云提供商: ${provider}`); 26 | } 27 | 28 | try { 29 | // 验证配置 30 | await syncProvider.validateConfig(config); 31 | 32 | // 执行同步 33 | const result = await syncProvider.uploadBackup(config, data); 34 | 35 | return { 36 | success: true, 37 | provider, 38 | data: result 39 | }; 40 | } catch (error) { 41 | return { 42 | success: false, 43 | provider, 44 | error: error.message 45 | }; 46 | } 47 | } 48 | 49 | /** 50 | * 测试连接 51 | * @param {string} provider - 提供商类型 52 | * @param {Object} config - 提供商配置 53 | * @returns {Promise} 测试结果 54 | */ 55 | async testConnection(provider, config) { 56 | const syncProvider = this.providers[provider]; 57 | if (!syncProvider) { 58 | throw new Error(`不支持的云提供商: ${provider}`); 59 | } 60 | 61 | try { 62 | await syncProvider.testConnection(config); 63 | return { success: true, message: '连接测试成功' }; 64 | } catch (error) { 65 | return { success: false, error: error.message }; 66 | } 67 | } 68 | 69 | /** 70 | * 获取支持的提供商列表 71 | * @returns {Array} 提供商列表 72 | */ 73 | getSupportedProviders() { 74 | return Object.keys(this.providers); 75 | } 76 | } 77 | 78 | /** 79 | * 云同步提供商基类 80 | */ 81 | class BaseSyncProvider { 82 | /** 83 | * 验证配置 84 | * @param {Object} config - 配置对象 85 | * @throws {Error} 配置无效时抛出错误 86 | */ 87 | async validateConfig(config) { 88 | throw new Error('子类必须实现 validateConfig 方法'); 89 | } 90 | 91 | /** 92 | * 测试连接 93 | * @param {Object} config - 配置对象 94 | * @throws {Error} 连接失败时抛出错误 95 | */ 96 | async testConnection(config) { 97 | throw new Error('子类必须实现 testConnection 方法'); 98 | } 99 | 100 | /** 101 | * 上传备份 102 | * @param {Object} config - 配置对象 103 | * @param {Object} data - 备份数据 104 | * @returns {Promise} 上传结果 105 | */ 106 | async uploadBackup(config, data) { 107 | throw new Error('子类必须实现 uploadBackup 方法'); 108 | } 109 | 110 | /** 111 | * Base64 编码工具(兼容中文) 112 | * @param {string} str - 要编码的字符串 113 | * @returns {string} Base64 编码结果 114 | */ 115 | toBase64(str) { 116 | try { 117 | return btoa(unescape(encodeURIComponent(str))); 118 | } catch (_) { 119 | const bytes = new TextEncoder().encode(str); 120 | let binary = ''; 121 | bytes.forEach(b => { binary += String.fromCharCode(b); }); 122 | return btoa(binary); 123 | } 124 | } 125 | 126 | /** 127 | * 生成 Chrome 兼容的书签 HTML 128 | * @param {Array} bookmarkTree - 书签树 129 | * @returns {string} HTML 字符串 130 | */ 131 | generateBookmarkHTML(bookmarkTree) { 132 | const escapeHtml = (text = '') => String(text) 133 | .replace(/&/g, '&') 134 | .replace(//g, '>') 136 | .replace(/"/g, '"') 137 | .replace(/'/g, '''); 138 | 139 | const processBookmarkNode = (node, depth, defaultTimestamp) => { 140 | const indent = ' '.repeat(depth); 141 | let html = ''; 142 | if (node.children) { 143 | const addDate = node.dateAdded ? Math.floor(node.dateAdded / 1000) : defaultTimestamp; 144 | const lastModified = node.dateGroupModified ? Math.floor(node.dateGroupModified / 1000) : defaultTimestamp; 145 | html += `${indent}

      ${escapeHtml(node.title || '未命名文件夹')}

      \n`; 146 | html += `${indent}

      \n`; 147 | for (const child of node.children) { 148 | html += processBookmarkNode(child, depth + 1, defaultTimestamp); 149 | } 150 | html += `${indent}

      \n`; 151 | } else if (node.url) { 152 | const addDate = node.dateAdded ? Math.floor(node.dateAdded / 1000) : defaultTimestamp; 153 | const icon = node.icon || ''; 154 | html += `${indent}

      ${escapeHtml(node.title || node.url)}\n`; 157 | } 158 | return html; 159 | }; 160 | 161 | const timestamp = Math.floor(Date.now() / 1000); 162 | let html = `\n\n\nBookmarks\n

      Bookmarks

      \n\n

      \n`; 163 | 164 | if (bookmarkTree && bookmarkTree.length > 0) { 165 | const rootNode = bookmarkTree[0]; 166 | if (rootNode.children) { 167 | for (const child of rootNode.children) { 168 | html += processBookmarkNode(child, 1, timestamp); 169 | } 170 | } 171 | } 172 | html += `

      \n`; 173 | return html; 174 | } 175 | } 176 | 177 | /** 178 | * GitHub 同步提供商 179 | */ 180 | class GitHubSyncProvider extends BaseSyncProvider { 181 | async validateConfig(config) { 182 | const { token, owner, repo } = config; 183 | if (!token || !owner || !repo) { 184 | throw new Error('GitHub 配置不完整:需要 token、owner 和 repo'); 185 | } 186 | } 187 | 188 | async testConnection(config) { 189 | const { token, owner, repo } = config; 190 | const response = await fetch(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { 191 | headers: { 192 | 'Authorization': `token ${token}`, 193 | 'Accept': 'application/vnd.github.v3+json' 194 | } 195 | }); 196 | 197 | if (!response.ok) { 198 | const errorText = await response.text(); 199 | throw new Error(`GitHub 连接失败 (${response.status}): ${errorText}`); 200 | } 201 | } 202 | 203 | async uploadBackup(config, data) { 204 | const { token, owner, repo, format = 'json', dualUpload = false } = config; 205 | 206 | // 获取仓库默认分支 207 | let branch = 'main'; 208 | try { 209 | const repoInfoRes = await fetch(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { 210 | headers: { 211 | 'Authorization': `token ${token}`, 212 | 'Accept': 'application/vnd.github.v3+json' 213 | } 214 | }); 215 | if (repoInfoRes.ok) { 216 | const info = await repoInfoRes.json(); 217 | if (info && typeof info.default_branch === 'string' && info.default_branch.trim()) { 218 | branch = info.default_branch.trim(); 219 | } 220 | } 221 | } catch (e) { 222 | console.warn('获取仓库默认分支失败,使用 main 作为默认', e); 223 | } 224 | 225 | const path = 'tidymark/backups/tidymark-backup.json'; 226 | const pathHtml = 'tidymark/backups/tidymark-bookmarks.html'; 227 | 228 | const uploadOne = async (filePath, contentStr) => { 229 | const segs = String(filePath).split('/').map(s => encodeURIComponent(s)).join('/'); 230 | const baseUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${segs}`; 231 | const headers = { 232 | 'Authorization': `token ${token}`, 233 | 'Accept': 'application/vnd.github.v3+json', 234 | 'Content-Type': 'application/json' 235 | }; 236 | 237 | // 获取现有文件的 SHA 238 | let sha; 239 | try { 240 | const getRes = await fetch(`${baseUrl}?ref=${encodeURIComponent(branch)}`, { headers }); 241 | if (getRes.status === 200) { 242 | const data = await getRes.json(); 243 | sha = data && data.sha; 244 | } 245 | } catch (e) { 246 | console.warn('检查现有文件失败(忽略)', e); 247 | } 248 | 249 | const body = { 250 | message: `TidyMark backup: ${new Date().toISOString()}`, 251 | content: this.toBase64(contentStr), 252 | branch 253 | }; 254 | if (sha) body.sha = sha; 255 | 256 | const putRes = await fetch(baseUrl, { 257 | method: 'PUT', 258 | headers, 259 | body: JSON.stringify(body) 260 | }); 261 | 262 | if (putRes.status === 201 || putRes.status === 200) { 263 | const data = await putRes.json(); 264 | return { success: true, data }; 265 | } 266 | 267 | const errText = await putRes.text(); 268 | throw new Error(`GitHub 上传失败 (${putRes.status}): ${errText}`); 269 | }; 270 | 271 | const results = []; 272 | if (dualUpload || format === 'json') { 273 | results.push(await uploadOne(path, JSON.stringify(data, null, 2))); 274 | } 275 | if (dualUpload || format === 'html') { 276 | const htmlStr = this.generateBookmarkHTML(data.bookmarks || []); 277 | results.push(await uploadOne(pathHtml, htmlStr)); 278 | } 279 | 280 | const last = results[results.length - 1]; 281 | return { 282 | contentPath: dualUpload ? `${path} & ${pathHtml}` : (format === 'json' ? path : pathHtml), 283 | htmlUrl: (last && last.data && last.data.content && last.data.content.html_url) || 284 | (last && last.data && last.data.commit && last.data.commit.html_url) || null 285 | }; 286 | } 287 | } 288 | 289 | /** 290 | * WebDAV 同步提供商 291 | */ 292 | class WebDAVSyncProvider extends BaseSyncProvider { 293 | async validateConfig(config) { 294 | const { baseUrl, username, password, targetPath } = config; 295 | if (!baseUrl || !username || !password) { 296 | throw new Error('WebDAV 配置不完整:需要基地址、用户名和密码'); 297 | } 298 | } 299 | 300 | async testConnection(config) { 301 | const { baseUrl, username, password } = config; 302 | const testUrl = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'; 303 | 304 | const response = await fetch(testUrl, { 305 | method: 'PROPFIND', 306 | headers: { 307 | 'Authorization': `Basic ${btoa(`${username}:${password}`)}`, 308 | 'Depth': '0', 309 | 'Content-Type': 'application/xml' 310 | }, 311 | body: '' 312 | }); 313 | 314 | if (!response.ok) { 315 | throw new Error(`WebDAV 连接失败 (${response.status}): ${response.statusText}`); 316 | } 317 | } 318 | 319 | async uploadBackup(config, data) { 320 | const { baseUrl, username, password, targetPath = 'tidymark', format = 'json', dualUpload = false } = config; 321 | const auth = `Basic ${this.toBase64(`${username}:${password}`)}`; 322 | 323 | // 确保目标路径存在(规范化去掉开头结尾斜杠) 324 | const normalizedPath = String(targetPath || 'tidymark').replace(/^\/+/, '').replace(/\/+$/, ''); 325 | await this.ensureDirectory(baseUrl, normalizedPath, auth); 326 | 327 | const uploadOne = async (fileName, content, contentType = 'application/json') => { 328 | const fileUrl = `${baseUrl.replace(/\/$/, '')}/${normalizedPath}/${fileName}`; 329 | 330 | const response = await fetch(fileUrl, { 331 | method: 'PUT', 332 | headers: { 333 | 'Authorization': auth, 334 | 'Content-Type': contentType 335 | }, 336 | body: content 337 | }); 338 | 339 | if (!response.ok) { 340 | throw new Error(`WebDAV 上传失败 (${response.status}): ${response.statusText}`); 341 | } 342 | 343 | return { success: true, url: fileUrl }; 344 | }; 345 | 346 | const results = []; 347 | if (dualUpload || format === 'json') { 348 | results.push(await uploadOne('tidymark-backup.json', JSON.stringify(data, null, 2))); 349 | } 350 | if (dualUpload || format === 'html') { 351 | const htmlStr = this.generateBookmarkHTML(data.bookmarks || []); 352 | results.push(await uploadOne('tidymark-bookmarks.html', htmlStr, 'text/html')); 353 | } 354 | 355 | return { 356 | contentPath: dualUpload ? 'tidymark-backup.json & tidymark-bookmarks.html' : 357 | (format === 'json' ? 'tidymark-backup.json' : 'tidymark-bookmarks.html'), 358 | uploadedFiles: results.map(r => r.url) 359 | }; 360 | } 361 | 362 | async ensureDirectory(baseUrl, path, auth) { 363 | const dirUrl = `${baseUrl.replace(/\/$/, '')}/${path}/`; 364 | 365 | try { 366 | // 检查目录是否存在 367 | const checkResponse = await fetch(dirUrl, { 368 | method: 'PROPFIND', 369 | headers: { 370 | 'Authorization': auth, 371 | 'Depth': '0', 372 | 'Content-Type': 'application/xml' 373 | }, 374 | body: '' 375 | }); 376 | 377 | if (checkResponse.status === 404) { 378 | // 目录不存在,创建它 379 | const createResponse = await fetch(dirUrl, { 380 | method: 'MKCOL', 381 | headers: { 382 | 'Authorization': auth 383 | } 384 | }); 385 | 386 | if (!createResponse.ok && createResponse.status !== 405) { // 405 表示目录已存在 387 | throw new Error(`创建目录失败 (${createResponse.status}): ${createResponse.statusText}`); 388 | } 389 | } 390 | } catch (error) { 391 | console.warn('检查/创建目录时出错:', error); 392 | } 393 | } 394 | } 395 | 396 | /** 397 | * Google Drive 同步提供商 398 | */ 399 | class GoogleDriveSyncProvider extends BaseSyncProvider { 400 | async validateConfig(config) { 401 | const { accessToken } = config; 402 | if (!accessToken) { 403 | throw new Error('Google Drive 配置不完整:需要访问令牌'); 404 | } 405 | } 406 | 407 | async testConnection(config) { 408 | const { accessToken } = config; 409 | 410 | const response = await fetch('https://www.googleapis.com/drive/v3/about?fields=user', { 411 | headers: { 412 | 'Authorization': `Bearer ${accessToken}`, 413 | 'Content-Type': 'application/json' 414 | } 415 | }); 416 | 417 | if (!response.ok) { 418 | const errorText = await response.text(); 419 | throw new Error(`Google Drive 连接失败 (${response.status}): ${errorText}`); 420 | } 421 | } 422 | 423 | async uploadBackup(config, data) { 424 | const { accessToken, folderId, format = 'json', dualUpload = false } = config; 425 | 426 | const uploadOne = async (fileName, content, mimeType = 'application/json') => { 427 | // 检查文件是否已存在 428 | let fileId = null; 429 | const searchQuery = folderId ? 430 | `name='${fileName}' and '${folderId}' in parents and trashed=false` : 431 | `name='${fileName}' and trashed=false`; 432 | 433 | const searchResponse = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(searchQuery)}`, { 434 | headers: { 435 | 'Authorization': `Bearer ${accessToken}` 436 | } 437 | }); 438 | 439 | if (searchResponse.ok) { 440 | const searchResult = await searchResponse.json(); 441 | if (searchResult.files && searchResult.files.length > 0) { 442 | fileId = searchResult.files[0].id; 443 | } 444 | } 445 | 446 | const metadata = { 447 | name: fileName, 448 | ...(folderId && !fileId && { parents: [folderId] }) 449 | }; 450 | 451 | const form = new FormData(); 452 | form.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' })); 453 | form.append('media', new Blob([content], { type: mimeType })); 454 | 455 | const url = fileId ? 456 | `https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=multipart` : 457 | 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'; 458 | 459 | const response = await fetch(url, { 460 | method: fileId ? 'PATCH' : 'POST', 461 | headers: { 462 | 'Authorization': `Bearer ${accessToken}` 463 | }, 464 | body: form 465 | }); 466 | 467 | if (!response.ok) { 468 | const errorText = await response.text(); 469 | throw new Error(`Google Drive 上传失败 (${response.status}): ${errorText}`); 470 | } 471 | 472 | const result = await response.json(); 473 | return { success: true, fileId: result.id, webViewLink: result.webViewLink }; 474 | }; 475 | 476 | const results = []; 477 | if (dualUpload || format === 'json') { 478 | results.push(await uploadOne('tidymark-backup.json', JSON.stringify(data, null, 2))); 479 | } 480 | if (dualUpload || format === 'html') { 481 | const htmlStr = this.generateBookmarkHTML(data.bookmarks || []); 482 | results.push(await uploadOne('tidymark-bookmarks.html', htmlStr, 'text/html')); 483 | } 484 | 485 | return { 486 | contentPath: dualUpload ? 'tidymark-backup.json & tidymark-bookmarks.html' : 487 | (format === 'json' ? 'tidymark-backup.json' : 'tidymark-bookmarks.html'), 488 | uploadedFiles: results.map(r => ({ fileId: r.fileId, webViewLink: r.webViewLink })) 489 | }; 490 | } 491 | } 492 | 493 | // 导出服务实例 494 | const cloudSyncService = new CloudSyncService(); 495 | 496 | // 将服务挂载到全局作用域(适配 background/service worker 环境) 497 | if (typeof globalThis !== 'undefined') { 498 | globalThis.CloudSyncService = cloudSyncService; 499 | } 500 | 501 | // 兼容浏览器环境 502 | if (typeof window !== 'undefined') { 503 | window.CloudSyncService = cloudSyncService; 504 | } 505 | 506 | // 兼容 Node.js 环境 507 | if (typeof module !== 'undefined' && module.exports) { 508 | module.exports = { CloudSyncService, cloudSyncService }; 509 | } --------------------------------------------------------------------------------