// ==UserScript== // @name linux.do 收藏夹 // @namespace http://tampermonkey.net/ // @version 6.4.1 // @description 收藏 linux.do 的帖子。 // @match https://linux.do/* // @match https://idcflare.com/* // @exclude https://linux.do/a/* // @exclude https://idcflare.com/a/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect * // @downloadURL https://update.greasyfork.icu/scripts/551642/linuxdo%20%E6%94%B6%E8%97%8F%E5%A4%B9.user.js // @updateURL https://update.greasyfork.icu/scripts/551642/linuxdo%20%E6%94%B6%E8%97%8F%E5%A4%B9.meta.js // ==/UserScript== (function () { "use strict"; // 使用常量管理所有键名、ID和类名 const CONSTANTS = { STORAGE_KEYS: { BOOKMARKS: "linuxdo_bookmarks", TRASH: "linuxdo_trash", // 回收站 WEBDAV_SERVER: "webdav_server", WEBDAV_USER: "webdav_user", WEBDAV_PASS: "webdav_pass", AUTO_SYNC: "webdav_auto_sync_enabled", TAG_ORDER: "bm_tag_order", FLOATING_BUTTON_POSITION: "bm_floating_button_position", }, IDS: { MANAGER_MODAL: "bookmark-manager-modal", SETTINGS_MODAL: "bookmark-settings-modal", // [NEW] 配置页面 WEBDAV_SETTINGS_MODAL: "webdav-settings-modal", WEBDAV_BROWSER_MODAL: "webdav-browser-modal", SEARCH_INPUT: "bookmark-search-input", TABLE_CONTAINER: "bookmarks-table-container", TABLE: "bookmarks-table", ROW_TEMPLATE: "bm-row-template", TAG_FILTER_CONTAINER: "bm-tag-filter-container", TAG_EDIT_INPUT: "bm-tag-edit-input", RENAME_TAGS_BUTTON: "rename-tags-btn", TRASH_TOGGLE_BUTTON: "toggle-trash-btn", EMPTY_TRASH_BUTTON: "empty-trash-btn", SETTINGS_BUTTON: "open-settings-btn", // [NEW] 打开配置按钮 WEBDAV_TEST_RESULT: "webdav-test-result", AUTO_SYNC_TOGGLE: "auto-sync-toggle", WEBDAV_BROWSER_LIST: "webdav-browser-list", MANAGE_BUTTON: "manage-bookmarks-button", PAGINATION_INFO: "bm-pagination-info", // [NEW] 分页信息 PAGINATION_CONTROLS: "bm-pagination-controls", // [NEW] 分页控件 FOOTER_CONTAINER: "bm-footer-container", // [NEW] 底部容器 PAGE_SIZE_SELECT: "bm-page-size-select", // [NEW] 每页显示数量选择器 TRASH_BACK_BUTTON: "return-to-bookmarks-btn", // [NEW] 回收站返回收藏按钮 }, CLASSES: { DELETE_BTN: "delete-btn", RESTORE_BTN: "restore-btn", PURGE_BTN: "purge-btn", RENAME_BTN: "rename-btn", SAVE_BTN: "save-btn", CANCEL_BTN: "cancel-btn", PIN_BTN: "pin-btn", UNPIN_BTN: "unpin-btn", PINNED_ROW: "pinned-bookmark", EDIT_INPUT: "edit-name-input", MODAL_BACKDROP: "bm-modal-backdrop", CLOSE_BTN: "bm-close-btn", CONTENT_PANEL: "bm-content-panel", ROW_HIDING: "bm-row-hiding", TAG_FILTER_BTN: "bm-tag-filter-btn", TAG_ACTIVE: "active", TAG_CELL: "bm-tag-cell", TAG_PILL: "bm-tag-pill", TAG_EDIT_BTN: "bm-tag-edit-btn", TAG_ADD_BTN: "bm-tag-add-btn", TAG_REMOVE_BTN: "bm-tag-remove-btn", TAG_SAVE_BTN: "bm-tag-save-btn", TAG_CANCEL_BTN: "bm-tag-cancel-btn", }, WEBDAV_DIR: "LinuxDoBookmarks/", }; const TAG_COLLATOR = new Intl.Collator(undefined, { sensitivity: "base", numeric: true, }); let activeTagFilter = null; // 用于存储当前激活的标签过滤器 let viewMode = "bookmarks"; // 视图模式:bookmarks | trash let currentPage = 1; // 当前页码 let itemsPerPage = 10; // 每页显示数量 let openModalCount = 0; function updateModalLockState() { const root = document.documentElement; const body = document.body; if (!root || !body) return; if (openModalCount > 0) { root.classList.add("bm-modal-open"); body.classList.add("bm-modal-open"); } else { root.classList.remove("bm-modal-open"); body.classList.remove("bm-modal-open"); } } function openModal(modal) { if (!modal || modal.style.display === "flex") return; modal.style.display = "flex"; openModalCount += 1; updateModalLockState(); } function closeModal(modal) { if (!modal || modal.style.display === "none") return; modal.style.display = "none"; openModalCount = Math.max(0, openModalCount - 1); updateModalLockState(); } // --- Part 1: 定义样式和 HTML --- GM_addStyle(` .bm-modal-backdrop { display: none; position: fixed; z-index: 2147483647; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4); justify-content: center; align-items: center; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; pointer-events: auto; } html.bm-modal-open, body.bm-modal-open { overflow: hidden !important; touch-action: none; } body.bm-modal-open .action-button { pointer-events: none; } .bm-content-panel { background-color: #ffffff; border-radius: 12px; padding: 25px 30px; border: 1px solid #EAEAEA; box-shadow: 0 10px 25px rgba(0,0,0,0.1); display: flex; flex-direction: column; } #${CONSTANTS.IDS.MANAGER_MODAL} .bm-content-panel { width: 1280px; height: 70vh; max-width: 95vw; max-height: 95vh; } .bm-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #EAEAEA; padding-bottom: 15px; margin-bottom: 20px; flex-shrink: 0; } .bm-header h2 { margin: 0; font-size: 24px; color: #333; font-weight: 600; } .bm-header-actions { display: flex; align-items: center; gap: 12px; margin-left: auto; } .bm-close-btn { color: #333; cursor: pointer; line-height: 1; transition: background-color 0.2s; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; padding: 0; margin: 0; background: none; border: none; border-radius: 4px; } .bm-close-btn svg { width: 20px; height: 20px; } .bm-close-btn:hover { background-color: #ff4444; color: #fff; } .bm-settings-btn { color: #333; font-size: 18px; font-weight: normal; cursor: pointer; line-height: 1; background: none; border: none; padding: 0; margin: 0; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; } .bm-settings-btn svg { width: 20px; height: 20px; } .search-input-container { position: relative; flex: 1; min-width: 200px; display: flex; align-items: center; height: 26px; border: 1px solid #DDD; border-radius: 4px; padding: 0 25px 0 10px; background: white; } #${CONSTANTS.IDS.SEARCH_INPUT} { width: 100%; height: 100%; padding: 0; margin: 0; font-size: 13px; border: none; outline: none; background: transparent; box-sizing: border-box; } .search-clear-btn { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 13px; height: 13px; border-radius: 50%; background-color: #555; color: #fff; border: none; cursor: pointer; display: none; align-items: center; justify-content: center; font-size: 10px; line-height: 1; padding: 0; transition: background-color 0.2s; } .search-clear-btn:hover { background-color: #ff4444; } .search-clear-btn.visible { display: flex; } .controls-buttons { display: flex; flex-wrap: wrap; gap: 8px; } #${CONSTANTS.IDS.TABLE_CONTAINER} { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; width: 100%; scrollbar-width: none; /* Firefox */ -ms-overflow-style: none; /* IE and Edge */ } #${CONSTANTS.IDS.TABLE_CONTAINER}::-webkit-scrollbar { display: none; /* Chrome, Safari and Opera */ } #${CONSTANTS.IDS.TABLE} { width: 100%; min-width: 100%; border-collapse: separate; border-spacing: 0; border: 1px solid #EAEAEA; border-radius: 8px; overflow: hidden; table-layout: fixed; } #${CONSTANTS.IDS.TABLE} th { position: sticky; top: 0; z-index: 1; background-color: #F9F9F9; padding: 12px 8px; text-align: left; border-bottom: 2px solid #EAEAEA; border-right: 1px solid #EAEAEA; box-sizing: border-box;} #${CONSTANTS.IDS.TABLE} th:last-child { border-right: none; } #${CONSTANTS.IDS.TABLE} td { border-bottom: 1px solid #EAEAEA; border-right: 1px solid #EAEAEA; padding: 12px 8px; text-align: left; transition: background-color 0.3s; vertical-align: middle; word-wrap: break-word; word-break: break-word; box-sizing: border-box; } #${CONSTANTS.IDS.TABLE} td:last-child { border-right: none; } #${CONSTANTS.IDS.TABLE} tr:last-child td { border-bottom: none; } #${CONSTANTS.IDS.TABLE} tbody tr:hover { background-color: #F8F9FA; } #${CONSTANTS.IDS.TABLE} td a { color: #007AFF; text-decoration: none; word-break: break-all; } #${CONSTANTS.IDS.TABLE} td a:hover { text-decoration: underline; } /* 固定列宽的单元格样式 - 允许内容换行 */ #${CONSTANTS.IDS.TABLE} th:nth-child(1), .bm-name-cell { width: 31%; word-wrap: break-word; word-break: break-word; } .bm-name-cell { font-size: 14px; } #${CONSTANTS.IDS.TABLE} th:nth-child(2), .bm-url-cell { width: 23%; word-wrap: break-word; } .bm-url-cell { font-size: 14px; } .bm-url-cell a { display: block; word-wrap: break-word; word-break: break-all; } #${CONSTANTS.IDS.TABLE} th:nth-child(3), .${CONSTANTS.CLASSES.TAG_CELL} { width: 12%; word-wrap: break-word; } #${CONSTANTS.IDS.TABLE} th:nth-child(4), .bm-time-cell { width: 12%; word-wrap: break-word; } .bm-time-cell { font-size: 13px !important; color: #666; } #${CONSTANTS.IDS.TABLE} th:nth-child(5), .bm-actions-cell { width: 22%; text-align: center; white-space: nowrap; } .bm-btn { border: 1px solid #CCC; background-color: #FFF; color: #333; padding: 4px 10px; height: 26px; border-radius: 5px; cursor: pointer; font-size: 13px; transition: all 0.2s; white-space: nowrap; display: inline-flex; align-items: center; justify-content: center; } .bm-actions-cell .bm-btn { padding: 4px 10px; font-size: 13px; margin: 0 2px; border-radius: 4px; } .bm-btn-io { border-color: #81C784; color: #2E7D32; } .bm-btn-cloud { border-color: #64B5F6; color: #1976D2; } .bm-btn-danger { border-color: #E57373; color: #D32F2F; } .bm-toast { position: fixed; bottom: 20px; right: 20px; z-index: 10001; background-color: #333; color: white; padding: 12px 20px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); opacity: 0; transition: opacity 0.3s, transform 0.3s; transform: translateY(20px); font-size: 15px; display: flex; align-items: center; gap: 10px; } .bm-toast.show { opacity: 1; transform: translateY(0); } .bm-toast.error { background-color: #D32F2F; } .bm-toast-action { color: #4CAF50; font-weight: bold; cursor: pointer; text-decoration: underline; } #${CONSTANTS.IDS.WEBDAV_BROWSER_MODAL} .bm-content-panel { max-width: 700px; height: 75vh; } #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} { list-style: none; padding: 0; margin: 0; overflow-y: auto; flex-grow: 1; border: 1px solid #eee; border-radius: 6px; } #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} li { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 15px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: background-color 0.2s; } #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} li:hover { background-color: #f5f5f5; } #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} li .webdav-backup-filename { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .webdav-delete-btn { flex-shrink: 0; padding: 4px 8px; font-size: 12px; } #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} li.loading-text { cursor: default; display: block; text-align: center; color: #888; pointer-events: none; } #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} li.loading-text:hover { background-color: transparent; } #${CONSTANTS.IDS.WEBDAV_SETTINGS_MODAL} .bm-content-panel { max-width: 550px; } .webdav-form-group { margin-bottom: 15px; } .webdav-form-group label { display: block; margin-bottom: 5px; color: #555; font-weight: 500; user-select: none;} .webdav-form-group input[type="checkbox"] { margin-right: 5px; vertical-align: middle; } .webdav-form-group input[type="text"], .webdav-form-group input[type="password"] { width: 100%; padding: 8px 12px; font-size: 15px; border-radius: 6px; border: 1px solid #DDD; box-sizing: border-box; } .webdav-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; flex-wrap: wrap; gap: 10px; } .webdav-footer-buttons { margin-left: auto; } .${CONSTANTS.CLASSES.PINNED_ROW} { background-color: #FFF8E1; } .${CONSTANTS.CLASSES.PINNED_ROW} td:first-child::before { content: ""; display: inline-block; width: 14px; height: 14px; margin-right: 4px; background-image: url('data:image/svg+xml;utf8,'); background-size: contain; background-repeat: no-repeat; vertical-align: middle; position: relative; top: -2px; } .action-button { position: fixed; z-index: 9998; padding: 10px 15px; background-color: #fff; color: #333; border: 1px solid #DDD; border-radius: 20px; cursor: grab; font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); right: 24px; top: 50%; transform: translateY(-50%); touch-action: none; user-select: none; } .action-button.dragging { cursor: grabbing; } #${CONSTANTS.IDS.MANAGE_BUTTON} { right: 20px; top: 50%; transform: translateY(-50%); display: flex; align-items: center; gap: 4px; } .heart-icon { font-size: 18px; transition: all 0.2s ease; cursor: pointer; user-select: none; } .heart-icon.empty { color: #999; } .heart-icon.filled { color: #ff4444; } .heart-icon:hover { transform: scale(1.2); } .divider { color: #DDD; margin: 0 2px; user-select: none; } .${CONSTANTS.CLASSES.ROW_HIDING} { opacity: 0; transform: scale(0.95); } #${CONSTANTS.IDS.TABLE} tr { transition: opacity 0.3s ease, transform 0.3s ease; } /* [NEW] Tag Styles - 优化版 */ #${CONSTANTS.IDS.TAG_FILTER_CONTAINER} { display: flex; flex-wrap: nowrap; gap: 0; margin-bottom: 15px; padding: 12px; background: linear-gradient(to bottom, #FAFAFA, #F5F5F5); border-radius: 8px; border: 1px solid #E8E8E8; align-items: center; overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; user-select: none; } #${CONSTANTS.IDS.TAG_FILTER_CONTAINER}::-webkit-scrollbar { display: none; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN} { border: 1px solid #DDD; background-color: #FFF; color: #555; padding: 3px 10px; height: 24px; border-radius: 12px; cursor: pointer; font-size: 12px; transition: all 0.2s; display: inline-flex; align-items: center; justify-content: center; box-shadow: 0 1px 2px rgba(0,0,0,0.05); flex: 0 0 auto; margin-right: 6px; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}.tag-draggable { cursor: grab; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}.tag-draggable:active { cursor: grabbing; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}.tag-dragging { opacity: 0.75; cursor: grabbing !important; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}:hover:not(.${CONSTANTS.CLASSES.TAG_ACTIVE}) { transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}.custom-tag { background: linear-gradient(135deg, #E8F5FE, #DAEFFF); border-color: #90CAF9; color: #1565C0; font-weight: 500; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}.original-tag { background-color: #F5F5F5; border-color: #CCC; color: #666; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}.${CONSTANTS.CLASSES.TAG_ACTIVE} { background: linear-gradient(135deg, #007AFF, #0066DD) !important; color: white !important; border-color: #0066DD !important; transform: scale(1.05); } .${CONSTANTS.CLASSES.TAG_PILL} { display: inline-block; background-color: #EFEFEF; color: #555; padding: 3px 8px; border-radius: 10px; font-size: 12px; margin-right: 5px; margin-bottom: 5px; position: relative; } .${CONSTANTS.CLASSES.TAG_PILL}.editable { padding-right: 20px; } .${CONSTANTS.CLASSES.TAG_REMOVE_BTN} { position: absolute; right: 2px; top: 50%; transform: translateY(-50%); background: none; border: none; color: #999; cursor: pointer; font-size: 10px; padding: 0; width: 14px; height: 14px; border-radius: 50%; display: flex; align-items: center; justify-content: center; } .${CONSTANTS.CLASSES.TAG_REMOVE_BTN}:hover { background-color: #ff4444; color: white; } .${CONSTANTS.CLASSES.TAG_EDIT_BTN} { background-color: #E8F5E8; color: #2E7D32; border: 1px solid #81C784; padding: 4px 10px; height: 26px; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; } .${CONSTANTS.CLASSES.TAG_ADD_BTN} { background-color: #E3F2FD; color: #1976D2; border: 1px solid #64B5F6; font-size: 12px; padding: 4px 8px; height: 26px; margin-left: 5px; display: inline-flex; align-items: center; justify-content: center; } .${CONSTANTS.IDS.TAG_EDIT_INPUT} { width: 120px; padding: 2px 6px; font-size: 12px; border: 1px solid #DDD; border-radius: 3px; margin-right: 5px; } .tag-edit-mode { background-color: #F5F5F5; padding: 8px; border-radius: 4px; display: flex; flex-wrap: wrap; gap: 5px; align-items: center; line-height: 1.8; } .tag-edit-mode br { width: 100%; margin: 4px 0; } .bm-custom-tag-header { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; width: 100%; } .bm-tag-dropdown { position: relative; display: inline-flex; } .bm-tag-dropdown-toggle { background-color: #FFF; color: #1976D2; border: 1px solid #64B5F6; font-size: 12px; padding: 4px 12px 4px 10px; border-radius: 14px; cursor: pointer; transition: all 0.2s; } .bm-tag-dropdown-toggle::after { content: "▼"; font-size: 10px; margin-left: 6px; transition: transform 0.2s; color: inherit; } .bm-tag-dropdown.open .bm-tag-dropdown-toggle::after { transform: rotate(180deg); } .bm-tag-dropdown-list { display: none; position: absolute; top: calc(100% + 4px); left: 0; min-width: 200px; max-height: 220px; overflow-y: auto; background: #FFF; border: 1px solid #90CAF9; border-radius: 6px; box-shadow: 0 8px 20px rgba(0,0,0,0.15); padding: 6px; z-index: 1000; } .bm-tag-dropdown.open .bm-tag-dropdown-list { display: block; } .bm-tag-option { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 4px; font-size: 12px; color: #1976D2; cursor: pointer; } .bm-tag-option:hover { background-color: #E3F2FD; } .bm-tag-option input { margin: 0; } .bm-selected-tags { display: flex; flex-wrap: wrap; gap: 6px; width: 100%; margin: 6px 0; } .bm-selected-tag-pill { display: inline-flex; align-items: center; gap: 4px; background-color: #E3F2FD; color: #1976D2; border-left: 3px solid #64B5F6; border-radius: 10px; padding: 2px 8px; font-size: 12px; position: relative; } .bm-selected-tag-pill .${CONSTANTS.CLASSES.TAG_REMOVE_BTN} { position: static; background: none; color: #1976D2; width: auto; height: auto; font-size: 12px; } .bm-selected-tag-pill .${CONSTANTS.CLASSES.TAG_REMOVE_BTN}:hover { background-color: #ff4444; color: #FFF; } .bm-tag-empty-hint { font-size: 12px; color: #999; margin: 4px 0; display: block; } .${CONSTANTS.CLASSES.TAG_SAVE_BTN}, .${CONSTANTS.CLASSES.TAG_CANCEL_BTN} { font-size: 12px; padding: 4px 8px; height: 26px; display: inline-flex; align-items: center; justify-content: center; } /* Settings Modal Styles */ #${CONSTANTS.IDS.SETTINGS_MODAL} .bm-content-panel { max-width: 800px; } .settings-sections { display: flex; flex-direction: column; gap: 20px; } .settings-section { background-color: #F9F9F9; border-radius: 8px; padding: 20px; border: 1px solid #EAEAEA; } .settings-section h3 { margin: 0 0 12px 0; font-size: 18px; color: #333; border-bottom: 2px solid #007AFF; padding-bottom: 8px; } .settings-buttons { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 8px; } .settings-desc { margin: 0; font-size: 13px; color: #666; line-height: 1.4; } /* Footer Styles - Left Search, Right Controls */ #${CONSTANTS.IDS.FOOTER_CONTAINER} { display: flex; flex-direction: row; justify-content: space-between; align-items: center; padding-top: 10px; margin-top: 10px; border-top: 2px solid #EAEAEA; flex-shrink: 0; gap: 20px; } .footer-left { display: flex; align-items: center; gap: 8px; flex: 0 1 350px; } .footer-right { display: flex; align-items: center; gap: 15px; flex-shrink: 0; } .footer-show { display: flex; align-items: center; gap: 6px; } #${CONSTANTS.IDS.PAGE_SIZE_SELECT} { padding: 0 2px; border: none; font-size: 14px; cursor: pointer; background-color: transparent; width: auto; color: #666; appearance: none; -webkit-appearance: none; -moz-appearance: none; outline: none; transition: color 0.2s; vertical-align: baseline; line-height: 1; margin: 0; font-family: inherit; } #${CONSTANTS.IDS.PAGE_SIZE_SELECT}:hover { color: #007AFF; } #${CONSTANTS.IDS.PAGE_SIZE_SELECT}:focus { color: #007AFF; } .search-label, .show-label { font-size: 13px; color: #666; margin: 0; padding: 0; line-height: 1; vertical-align: baseline; } #${CONSTANTS.IDS.PAGINATION_INFO} { font-size: 13px; color: #666; } #${CONSTANTS.IDS.PAGINATION_CONTROLS} { display: flex; gap: 4px; align-items: center; } .pagination-btn { padding: 4px 8px; border: 1px solid #DDD; background-color: #FFF; border-radius: 4px; cursor: pointer; font-size: 13px; transition: all 0.2s; min-width: 32px; text-align: center; } .pagination-btn:hover:not(:disabled) { background-color: #F0F0F0; border-color: #007AFF; } .pagination-btn:disabled { opacity: 0.4; cursor: not-allowed; } .pagination-btn.active { background-color: #007AFF; color: white; border-color: #007AFF; } /* 移动端响应式样式 */ @media (max-width: 768px) { /* 模态框调整 */ #${CONSTANTS.IDS.MANAGER_MODAL} .bm-content-panel { width: 100%; height: 100%; max-width: 100%; max-height: 100%; border-radius: 0; padding: 15px; } #${CONSTANTS.IDS.SETTINGS_MODAL} .bm-content-panel, #${CONSTANTS.IDS.WEBDAV_SETTINGS_MODAL} .bm-content-panel, #${CONSTANTS.IDS.WEBDAV_BROWSER_MODAL} .bm-content-panel { width: 100%; max-width: 100%; padding: 15px; border-radius: 0; } /* 标题和按钮调整 */ .bm-header h2 { font-size: 18px; } .bm-header-actions { gap: 8px; } /* 标签过滤器优化 */ #${CONSTANTS.IDS.TAG_FILTER_CONTAINER} { padding: 8px; gap: 4px; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN} { font-size: 11px; padding: 2px 8px; height: 22px; } /* 隐藏表格,使用卡片布局 */ #${CONSTANTS.IDS.TABLE} { display: block; border: none; } #${CONSTANTS.IDS.TABLE} thead { display: none; } #${CONSTANTS.IDS.TABLE} tbody { display: block; } #${CONSTANTS.IDS.TABLE} tr { display: block; margin-bottom: 12px; border: 1px solid #EAEAEA; border-radius: 8px; padding: 12px; background-color: #FFF; } #${CONSTANTS.IDS.TABLE} tr:hover { background-color: #F8F9FA; } #${CONSTANTS.IDS.TABLE} td { display: block; border: none; padding: 6px 0; width: 100% !important; text-align: left !important; } #${CONSTANTS.IDS.TABLE} td::before { content: attr(data-label); font-weight: 600; color: #666; display: block; margin-bottom: 4px; font-size: 12px; } /* 名称单元格 */ .bm-name-cell { font-size: 15px; font-weight: 600; color: #333; padding-top: 0 !important; } .bm-name-cell::before { display: none; } /* URL 单元格 */ .bm-url-cell { font-size: 13px; } /* 时间单元格 */ .bm-time-cell { font-size: 12px !important; } /* 操作按钮单元格 */ .bm-actions-cell { display: flex !important; flex-wrap: wrap; gap: 6px; padding-top: 8px !important; } .bm-actions-cell::before { width: 100%; } .bm-actions-cell .bm-btn { flex: 1 1 auto; min-width: calc(50% - 3px); font-size: 12px; padding: 6px 8px; height: 32px; } /* 底部搜索和分页 */ #${CONSTANTS.IDS.FOOTER_CONTAINER} { flex-direction: column; gap: 12px; padding-top: 12px; } .footer-left { width: 100%; flex: 1 1 100%; } .footer-right { width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 8px; } .search-input-container { min-width: 0; flex: 1; } #${CONSTANTS.IDS.PAGINATION_INFO} { font-size: 12px; order: 1; } #${CONSTANTS.IDS.PAGINATION_CONTROLS} { order: 3; width: 100%; justify-content: center; } .footer-show { order: 2; font-size: 12px; } .pagination-btn { font-size: 12px; padding: 6px 10px; min-width: 36px; height: 36px; } /* 控制按钮组 */ .controls-buttons { gap: 6px; } .controls-buttons .bm-btn { font-size: 12px; padding: 6px 10px; height: 32px; } /* 浮动按钮调整 */ .action-button { font-size: 13px; padding: 8px 12px; border-radius: 18px; } /* 表单调整 */ .webdav-form-group input[type="text"], .webdav-form-group input[type="password"] { font-size: 14px; } /* Toast 通知 */ .bm-toast { font-size: 13px; padding: 10px 16px; bottom: 12px; right: 12px; left: 12px; max-width: calc(100% - 24px); } /* 配置页面调整 */ .settings-section h3 { font-size: 16px; } .settings-desc { font-size: 12px; } .settings-buttons .bm-btn { font-size: 12px; padding: 6px 10px; height: 32px; } /* WebDAV 浏览器列表 */ #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} li { flex-direction: column; align-items: flex-start; gap: 8px; } .webdav-delete-btn { align-self: flex-end; } /* 触摸目标优化 */ .bm-close-btn, .bm-settings-btn { width: 32px; height: 32px; } .bm-close-btn svg, .bm-settings-btn svg { width: 22px; height: 22px; } /* 标签编辑 */ .${CONSTANTS.IDS.TAG_EDIT_INPUT} { width: 100%; font-size: 14px; padding: 6px 8px; } .${CONSTANTS.CLASSES.TAG_PILL} { font-size: 11px; } /* 搜索清除按钮 */ .search-clear-btn { width: 16px; height: 16px; font-size: 11px; right: 8px; } /* 置顶行样式 */ .${CONSTANTS.CLASSES.PINNED_ROW} td:first-child::before { content: ""; display: inline-block; width: 14px; height: 14px; margin-right: 4px; background-image: url('data:image/svg+xml;utf8,'); background-size: contain; background-repeat: no-repeat; vertical-align: middle; position: relative; top: -2px; } } `); document.body.insertAdjacentHTML( "beforeend", `

收藏夹

配置与管理

数据管理

从本地文件导入或导出收藏数据

云端同步

通过 WebDAV 在多设备间同步收藏

标签管理

批量重命名自定义标签

回收站

查看已删除的收藏或清空回收站

WebDAV 云同步设置

选择一个云端备份进行恢复

` ); // --- Part 2: DOM 元素获取与核心变量 --- const getEl = (id) => document.getElementById(id); const managerModal = getEl(CONSTANTS.IDS.MANAGER_MODAL); const settingsModal = getEl(CONSTANTS.IDS.SETTINGS_MODAL); const webdavSettingsModal = getEl(CONSTANTS.IDS.WEBDAV_SETTINGS_MODAL); const webdavBrowserModal = getEl(CONSTANTS.IDS.WEBDAV_BROWSER_MODAL); const trashBackButton = getEl(CONSTANTS.IDS.TRASH_BACK_BUTTON); const searchInput = getEl(CONSTANTS.IDS.SEARCH_INPUT); const tableContainer = getEl(CONSTANTS.IDS.TABLE_CONTAINER); const webdavTestResult = getEl(CONSTANTS.IDS.WEBDAV_TEST_RESULT); const autoSyncToggle = getEl(CONSTANTS.IDS.AUTO_SYNC_TOGGLE); const webdavBrowserList = getEl(CONSTANTS.IDS.WEBDAV_BROWSER_LIST); const rowTemplate = getEl(CONSTANTS.IDS.ROW_TEMPLATE); const tagFilterContainer = getEl(CONSTANTS.IDS.TAG_FILTER_CONTAINER); const pageSizeSelect = getEl(CONSTANTS.IDS.PAGE_SIZE_SELECT); const fileInput = document.createElement("input"); fileInput.type = "file"; fileInput.accept = ".json"; fileInput.style.display = "none"; document.body.appendChild(fileInput); // --- Part 3: 核心函数 --- const getRootTopicUrl = (url) => (url.match(/(https:\/\/(?:linux\.do|idcflare\.com)\/t\/[^\/]+\/\d+)/) || [])[0] || url; const pad = (num) => num.toString().padStart(2, "0"); const getTimestampedFilename = () => { const d = new Date(); const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad( d.getDate() )}`; const time = `${pad(d.getHours())}-${pad(d.getMinutes())}-${pad( d.getSeconds() )}`; return `linuxdo-backup-${date}_${time}.json`; }; function showToast(message, options = {}) { const { isError = false, duration = 3000, actions = [] } = options; const toast = document.createElement("div"); toast.className = `bm-toast ${isError ? "error" : ""}`; const messageSpan = document.createElement("span"); messageSpan.textContent = message; toast.appendChild(messageSpan); actions.forEach((action) => { const actionLink = document.createElement("a"); actionLink.textContent = action.text; actionLink.className = "bm-toast-action"; actionLink.onclick = (e) => { e.stopPropagation(); action.onClick(); toast.remove(); }; toast.appendChild(actionLink); }); document.body.appendChild(toast); setTimeout(() => { toast.classList.add("show"); setTimeout(() => { toast.classList.remove("show"); setTimeout(() => toast.remove(), 500); }, duration); }, 10); } const getTagOrderKey = (tag, isCustom) => `${isCustom ? "custom" : "original"}:${tag}`; const tagDragState = { draggedButton: null, hasMoved: false, }; function renderTagFilters() { // 在回收站视图隐藏标签筛选 if (viewMode === "trash") { tagFilterContainer.style.display = "none"; return; } tagFilterContainer.style.display = "block"; const allBookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const originalTags = new Set(); // 帖子自带的标签 const customTags = new Set(); // 用户自定义的标签 allBookmarks.forEach((bm) => { // 帖子自带的标签(从 tags 数组获取) bm.tags?.forEach((tag) => originalTags.add(tag)); // 用户自定义的标签(从 customTags 数组获取) bm.customTags?.forEach((tag) => customTags.add(tag)); }); // 清空容器 tagFilterContainer.innerHTML = ""; ensureTagDragHandlersInitialized(); const createButton = (text, tag, isCustom = false) => { const btn = document.createElement("button"); btn.textContent = text; btn.className = CONSTANTS.CLASSES.TAG_FILTER_BTN; btn.dataset.tag = tag === null ? "" : tag; btn.dataset.isCustom = isCustom; if (tag !== null) { btn.classList.add(isCustom ? "custom-tag" : "original-tag"); btn.dataset.tagKey = getTagOrderKey(tag, isCustom); btn.draggable = true; btn.classList.add("tag-draggable"); } else { btn.draggable = false; } if (activeTagFilter === tag) { btn.classList.add(CONSTANTS.CLASSES.TAG_ACTIVE); } return btn; }; // 添加"所有标签"按钮 tagFilterContainer.appendChild(createButton("所有标签", null)); const storedOrderRaw = GM_getValue(CONSTANTS.STORAGE_KEYS.TAG_ORDER, []); const tagEntries = []; Array.from(originalTags).forEach((tag) => { tagEntries.push({ label: tag, tag, isCustom: false, key: getTagOrderKey(tag, false), }); }); Array.from(customTags).forEach((tag) => { tagEntries.push({ label: tag, tag, isCustom: true, key: getTagOrderKey(tag, true), }); }); const availableKeys = new Set(tagEntries.map((entry) => entry.key)); const cleanedOrder = storedOrderRaw.filter((key) => availableKeys.has(key) ); if (cleanedOrder.length !== storedOrderRaw.length) { GM_setValue(CONSTANTS.STORAGE_KEYS.TAG_ORDER, cleanedOrder); } const orderMap = new Map(); cleanedOrder.forEach((key, index) => orderMap.set(key, index)); tagEntries.sort((a, b) => { const idxA = orderMap.has(a.key) ? orderMap.get(a.key) : Number.MAX_SAFE_INTEGER; const idxB = orderMap.has(b.key) ? orderMap.get(b.key) : Number.MAX_SAFE_INTEGER; if (idxA !== idxB) return idxA - idxB; if (a.isCustom !== b.isCustom) return a.isCustom ? 1 : -1; return TAG_COLLATOR.compare(a.tag, b.tag); }); let orderUpdated = false; tagEntries.forEach((entry) => { if (!orderMap.has(entry.key)) { orderMap.set(entry.key, cleanedOrder.length); cleanedOrder.push(entry.key); orderUpdated = true; } }); if (orderUpdated) { GM_setValue(CONSTANTS.STORAGE_KEYS.TAG_ORDER, cleanedOrder); } tagEntries.forEach((entry) => { tagFilterContainer.appendChild( createButton(entry.label, entry.tag, entry.isCustom) ); }); } function ensureTagDragHandlersInitialized() { if (ensureTagDragHandlersInitialized.initialized || !tagFilterContainer) { return; } tagFilterContainer.addEventListener("dragstart", handleTagDragStart); tagFilterContainer.addEventListener("dragover", handleTagDragOver); tagFilterContainer.addEventListener("drop", handleTagDrop); tagFilterContainer.addEventListener("dragend", handleTagDragEnd); ensureTagDragHandlersInitialized.initialized = true; } ensureTagDragHandlersInitialized.initialized = false; function handleTagDragStart(event) { const button = event.target.closest(`.${CONSTANTS.CLASSES.TAG_FILTER_BTN}`); if (!button || !button.dataset.tagKey) return; tagDragState.draggedButton = button; tagDragState.hasMoved = false; button.classList.add("tag-dragging"); if (event.dataTransfer) { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", button.dataset.tag || ""); } } function handleTagDragOver(event) { if (!tagDragState.draggedButton) return; event.preventDefault(); const container = tagFilterContainer; if (!container) return; const hoveredButton = event.target.closest(`.${CONSTANTS.CLASSES.TAG_FILTER_BTN}`); if (!hoveredButton || hoveredButton === tagDragState.draggedButton) { return; } if (!hoveredButton.dataset.tagKey) { return; } const rect = hoveredButton.getBoundingClientRect(); const isBefore = event.clientX < rect.left + rect.width / 2; const referenceNode = isBefore ? hoveredButton : hoveredButton.nextSibling; if (referenceNode !== tagDragState.draggedButton) { container.insertBefore(tagDragState.draggedButton, referenceNode); tagDragState.hasMoved = true; } } function handleTagDrop(event) { if (!tagDragState.draggedButton) return; event.preventDefault(); } function handleTagDragEnd() { if (tagDragState.draggedButton) { tagDragState.draggedButton.classList.remove("tag-dragging"); } if (tagDragState.hasMoved) { updateTagOrderStorage(); } tagDragState.draggedButton = null; tagDragState.hasMoved = false; } function updateTagOrderStorage() { if (!tagFilterContainer) return; const buttons = tagFilterContainer.querySelectorAll( `.${CONSTANTS.CLASSES.TAG_FILTER_BTN}` ); const newOrder = []; buttons.forEach((btn) => { if (btn.dataset.tagKey) { newOrder.push(btn.dataset.tagKey); } }); const currentOrder = GM_getValue( CONSTANTS.STORAGE_KEYS.TAG_ORDER, [] ); if ( currentOrder.length === newOrder.length && currentOrder.every((key, index) => key === newOrder[index]) ) { return; } GM_setValue(CONSTANTS.STORAGE_KEYS.TAG_ORDER, newOrder); } function renderBookmarksTable() { if (viewMode === "trash") { renderTrashTable(); return; } const searchText = searchInput.value.toLowerCase(); const allBookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const filteredBookmarks = allBookmarks.filter((bm) => { const hasTag = !activeTagFilter || (bm.tags && bm.tags.includes(activeTagFilter)) || (bm.customTags && bm.customTags.includes(activeTagFilter)); const hasText = !searchText || bm.name.toLowerCase().includes(searchText) || bm.url.toLowerCase().includes(searchText) || (bm.tags && bm.tags.some((t) => t.toLowerCase().includes(searchText))) || (bm.customTags && bm.customTags.some((t) => t.toLowerCase().includes(searchText))); return hasTag && hasText; }); if (filteredBookmarks.length === 0) { tableContainer.innerHTML = '

没有找到匹配的收藏。

'; updatePagination(0, 0); return; } const sortedBookmarks = [...filteredBookmarks].sort((a, b) => a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1 ); // 分页计算 const totalItems = sortedBookmarks.length; const totalPages = Math.ceil(totalItems / itemsPerPage); if (currentPage > totalPages) currentPage = totalPages; if (currentPage < 1) currentPage = 1; const startIndex = (currentPage - 1) * itemsPerPage; const endIndex = Math.min(startIndex + itemsPerPage, totalItems); const pageBookmarks = sortedBookmarks.slice(startIndex, endIndex); const table = document.createElement("table"); table.id = CONSTANTS.IDS.TABLE; table.innerHTML = `名称链接标签收藏时间操作`; const tbody = document.createElement("tbody"); pageBookmarks.forEach((bookmark) => { const row = rowTemplate.content.cloneNode(true).firstElementChild; row.dataset.urlKey = getRootTopicUrl(bookmark.url); row.querySelector(".bm-name-cell").textContent = bookmark.name; const link = row.querySelector(".bm-url-cell a"); link.href = bookmark.url; link.textContent = bookmark.url; link.title = bookmark.url; const tagCell = row.querySelector(`.${CONSTANTS.CLASSES.TAG_CELL}`); // 显示帖子自带的标签 if (bookmark.tags && bookmark.tags.length > 0) { bookmark.tags.forEach((tag) => { const pill = document.createElement("span"); pill.className = `${CONSTANTS.CLASSES.TAG_PILL}`; pill.textContent = tag; pill.style.backgroundColor = "#EFEFEF"; pill.style.color = "#555"; tagCell.appendChild(pill); }); } // 显示用户自定义的标签 if (bookmark.customTags && bookmark.customTags.length > 0) { bookmark.customTags.forEach((tag) => { const pill = document.createElement("span"); pill.className = `${CONSTANTS.CLASSES.TAG_PILL} editable`; pill.textContent = tag; pill.style.backgroundColor = "#E3F2FD"; pill.style.color = "#1976D2"; pill.style.borderLeft = "3px solid #64B5F6"; // 添加删除按钮(只有自定义标签可以删除) const removeBtn = document.createElement("button"); removeBtn.className = CONSTANTS.CLASSES.TAG_REMOVE_BTN; removeBtn.textContent = "×"; removeBtn.title = "删除自定义标签"; removeBtn.onclick = (e) => { e.stopPropagation(); removeCustomTagFromBookmark(getRootTopicUrl(bookmark.url), tag); }; pill.appendChild(removeBtn); tagCell.appendChild(pill); }); } // 显示收藏时间 const timeCell = row.querySelector(".bm-time-cell"); if (bookmark.timestamp) { const date = new Date(bookmark.timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); timeCell.textContent = `${year}-${month}-${day} ${hours}:${minutes}`; } else { timeCell.textContent = "未知"; } const pinBtn = row.querySelector(`.${CONSTANTS.CLASSES.PIN_BTN}`); if (bookmark.pinned) { row.classList.add(CONSTANTS.CLASSES.PINNED_ROW); pinBtn.style.backgroundColor = "#f39c12"; pinBtn.style.color = "#fff"; pinBtn.classList.add(CONSTANTS.CLASSES.UNPIN_BTN); } else { pinBtn.style.backgroundColor = ""; pinBtn.style.color = ""; pinBtn.classList.remove(CONSTANTS.CLASSES.UNPIN_BTN); } tbody.appendChild(row); }); table.appendChild(tbody); tableContainer.innerHTML = ""; tableContainer.appendChild(table); // 更新分页信息 updatePagination(totalItems, totalPages); } // 更新分页信息和控件 function updatePagination(totalItems, totalPages) { const paginationInfo = getEl(CONSTANTS.IDS.PAGINATION_INFO); const paginationControls = getEl(CONSTANTS.IDS.PAGINATION_CONTROLS); if (totalItems === 0) { paginationInfo.textContent = ""; paginationControls.innerHTML = ""; return; } // 显示信息: "Showing 1 to 20 of 432 entries" const startIndex = (currentPage - 1) * itemsPerPage + 1; const endIndex = Math.min(currentPage * itemsPerPage, totalItems); paginationInfo.textContent = `Showing ${startIndex} to ${endIndex} of ${totalItems} entries`; // 生成分页按钮 paginationControls.innerHTML = ""; // 上一页按钮 const prevBtn = document.createElement("button"); prevBtn.className = "pagination-btn"; prevBtn.textContent = "<"; prevBtn.disabled = currentPage === 1; prevBtn.onclick = () => { if (currentPage > 1) { currentPage--; renderBookmarksTable(); } }; paginationControls.appendChild(prevBtn); // 页码按钮 (显示当前页附近的页码) const maxButtons = 5; let startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2)); let endPage = Math.min(totalPages, startPage + maxButtons - 1); if (endPage - startPage < maxButtons - 1) { startPage = Math.max(1, endPage - maxButtons + 1); } if (startPage > 1) { const firstBtn = document.createElement("button"); firstBtn.className = "pagination-btn"; firstBtn.textContent = "1"; firstBtn.onclick = () => { currentPage = 1; renderBookmarksTable(); }; paginationControls.appendChild(firstBtn); if (startPage > 2) { const dots = document.createElement("span"); dots.textContent = "..."; dots.style.padding = "0 8px"; paginationControls.appendChild(dots); } } for (let i = startPage; i <= endPage; i++) { const pageBtn = document.createElement("button"); pageBtn.className = "pagination-btn" + (i === currentPage ? " active" : ""); pageBtn.textContent = i; pageBtn.onclick = () => { currentPage = i; renderBookmarksTable(); }; paginationControls.appendChild(pageBtn); } if (endPage < totalPages) { if (endPage < totalPages - 1) { const dots = document.createElement("span"); dots.textContent = "..."; dots.style.padding = "0 8px"; paginationControls.appendChild(dots); } const lastBtn = document.createElement("button"); lastBtn.className = "pagination-btn"; lastBtn.textContent = totalPages; lastBtn.onclick = () => { currentPage = totalPages; renderBookmarksTable(); }; paginationControls.appendChild(lastBtn); } // 下一页按钮 const nextBtn = document.createElement("button"); nextBtn.className = "pagination-btn"; nextBtn.textContent = ">"; nextBtn.disabled = currentPage === totalPages; nextBtn.onclick = () => { if (currentPage < totalPages) { currentPage++; renderBookmarksTable(); } }; paginationControls.appendChild(nextBtn); } // [NEW] 回收站渲染 function renderTrashTable() { const searchText = searchInput.value.toLowerCase(); const allTrash = GM_getValue(CONSTANTS.STORAGE_KEYS.TRASH, []); const filtered = allTrash.filter((bm) => { const hasText = !searchText || (bm.name && bm.name.toLowerCase().includes(searchText)) || (bm.url && bm.url.toLowerCase().includes(searchText)) || (bm.tags && bm.tags.some((t) => t.toLowerCase().includes(searchText))) || (bm.customTags && bm.customTags.some((t) => t.toLowerCase().includes(searchText))); return hasText; }); if (filtered.length === 0) { tableContainer.innerHTML = '

回收站为空或没有匹配项。

'; return; } const table = document.createElement("table"); table.id = CONSTANTS.IDS.TABLE; table.innerHTML = `名称链接标签收藏时间操作`; const tbody = document.createElement("tbody"); filtered.forEach((bm) => { const tr = document.createElement("tr"); tr.dataset.urlKey = getRootTopicUrl(bm.url); const nameTd = document.createElement("td"); nameTd.textContent = bm.name || "(无标题)"; const urlTd = document.createElement("td"); const a = document.createElement("a"); a.href = bm.url; a.target = "_blank"; a.textContent = bm.url; a.title = bm.url; urlTd.appendChild(a); const tagTd = document.createElement("td"); if (bm.tags) bm.tags.forEach((t) => { const s = document.createElement("span"); s.className = `${CONSTANTS.CLASSES.TAG_PILL}`; s.textContent = t; tagTd.appendChild(s); }); if (bm.customTags) bm.customTags.forEach((t) => { const s = document.createElement("span"); s.className = `${CONSTANTS.CLASSES.TAG_PILL}`; s.textContent = t; s.style.backgroundColor = "#E3F2FD"; s.style.color = "#1976D2"; s.style.borderLeft = "3px solid #64B5F6"; tagTd.appendChild(s); }); // 添加时间列 const timeTd = document.createElement("td"); timeTd.style.fontSize = "12px"; timeTd.style.color = "#666"; if (bm.timestamp) { const date = new Date(bm.timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); timeTd.textContent = `${year}-${month}-${day} ${hours}:${minutes}`; } else { timeTd.textContent = "未知"; } const actTd = document.createElement("td"); actTd.style.textAlign = "center"; actTd.style.whiteSpace = "nowrap"; const restoreBtn = document.createElement("button"); restoreBtn.className = `bm-btn bm-btn-restore ${CONSTANTS.CLASSES.RESTORE_BTN}`; restoreBtn.textContent = "恢复"; const purgeBtn = document.createElement("button"); purgeBtn.className = `bm-btn bm-btn-purge ${CONSTANTS.CLASSES.PURGE_BTN}`; purgeBtn.style.marginLeft = "6px"; purgeBtn.textContent = "彻底删除"; actTd.appendChild(restoreBtn); actTd.appendChild(purgeBtn); tr.appendChild(nameTd); tr.appendChild(urlTd); tr.appendChild(tagTd); tr.appendChild(timeTd); tr.appendChild(actTd); tbody.appendChild(tr); }); table.appendChild(tbody); tableContainer.innerHTML = ""; tableContainer.appendChild(table); } function modifyBookmarks(updateFunction) { let bookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const result = updateFunction(bookmarks); if (result === false) return; GM_setValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, result.bookmarks); if (result.changed) triggerAutoWebDAVSync(); return result.bookmarks; } // [NEW] 回收站修改器 function modifyTrash(updateFunction) { let trash = GM_getValue(CONSTANTS.STORAGE_KEYS.TRASH, []); const result = updateFunction(trash); if (result === false) return; GM_setValue(CONSTANTS.STORAGE_KEYS.TRASH, result.trash); return result.trash; } // 批量重命名自定义标签(将 oldTag -> newTag) function bulkRenameCustomTag(oldTag, newTag) { if (!oldTag || !newTag || oldTag === newTag) { showToast("无效的标签重命名参数", { isError: true }); return; } let changedCount = 0; modifyBookmarks((bookmarks) => { bookmarks.forEach((bm) => { if (bm.customTags && bm.customTags.includes(oldTag)) { // 避免重复:如果 newTag 已存在,则仅删除 oldTag if (bm.customTags.includes(newTag)) { bm.customTags = bm.customTags.filter((t) => t !== oldTag); } else { bm.customTags = bm.customTags.map((t) => t === oldTag ? newTag : t ); } // 清空后删除字段 if (bm.customTags.length === 0) delete bm.customTags; changedCount++; } }); return { bookmarks, changed: changedCount > 0 }; }); if (changedCount > 0) { renderBookmarksTable(); renderTagFilters(); showToast(`已重命名 ${changedCount} 条中的自定义标签`); } else { showToast("未找到需要重命名的自定义标签", { isError: true }); } } // 通过 prompt 启动批量重命名流程 function startBulkRenameFlow(presetOldTag = "") { const oldTag = presetOldTag || prompt("请输入需要重命名的【旧自定义标签】(精确匹配):"); if (!oldTag) return; const newTag = prompt(`将自定义标签 "${oldTag}" 重命名为:`); if (!newTag) return; if (/[,/]/.test(newTag)) { showToast("新标签中不允许包含逗号或斜杠", { isError: true }); return; } bulkRenameCustomTag(oldTag.trim(), newTag.trim()); } // 标签管理功能 function removeTagFromBookmark(urlKey, tagToRemove) { modifyBookmarks((bookmarks) => { const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (bookmark && bookmark.tags) { bookmark.tags = bookmark.tags.filter((tag) => tag !== tagToRemove); if (bookmark.tags.length === 0) { delete bookmark.tags; } } return { bookmarks, changed: true }; }); renderBookmarksTable(); renderTagFilters(); showToast(`已删除标签: ${tagToRemove}`); } function removeCustomTagFromBookmark(urlKey, tagToRemove) { modifyBookmarks((bookmarks) => { const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (bookmark && bookmark.customTags) { bookmark.customTags = bookmark.customTags.filter( (tag) => tag !== tagToRemove ); if (bookmark.customTags.length === 0) { delete bookmark.customTags; } } return { bookmarks, changed: true }; }); renderBookmarksTable(); renderTagFilters(); showToast(`已删除自定义标签: ${tagToRemove}`); } function addTagToBookmark(urlKey, newTag) { if (!newTag || !newTag.trim()) { showToast("标签名称不能为空!", { isError: true }); return; } newTag = newTag.trim(); modifyBookmarks((bookmarks) => { const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (bookmark) { if (!bookmark.customTags) { bookmark.customTags = []; } // 检查是否与原生标签重复 if (bookmark.tags && bookmark.tags.includes(newTag)) { showToast("该标签已存在于帖子标签中!", { isError: true }); return false; } // 检查是否与自定义标签重复 if (!bookmark.customTags.includes(newTag)) { bookmark.customTags.push(newTag); return { bookmarks, changed: true }; } else { showToast("该自定义标签已存在!", { isError: true }); return false; } } return false; }); renderBookmarksTable(); renderTagFilters(); showToast(`已添加自定义标签: ${newTag}`); } function getAllCustomTags() { const allBookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const tagSet = new Set(); allBookmarks.forEach((bookmark) => { if (!bookmark?.customTags) return; bookmark.customTags.forEach((tag) => { if (typeof tag === "string") { const trimmed = tag.trim(); if (trimmed) { tagSet.add(trimmed); } } }); }); return Array.from(tagSet).sort((a, b) => TAG_COLLATOR.compare(a, b)); } function setCustomTagsForBookmark(urlKey, tags) { const sanitizedTags = Array.from( new Set( tags .map((tag) => (typeof tag === "string" ? tag.trim() : "")) .filter((tag) => tag.length > 0) ) ); let conflictTag = null; let changed = false; let bookmarkFound = false; modifyBookmarks((bookmarks) => { const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (!bookmark) { return false; } bookmarkFound = true; const originalTags = bookmark.tags || []; const conflict = sanitizedTags.find((tag) => originalTags.includes(tag)); if (conflict) { conflictTag = conflict; return false; } if (sanitizedTags.length === 0) { if (bookmark.customTags) { delete bookmark.customTags; changed = true; return { bookmarks, changed: true }; } return { bookmarks, changed: false }; } const current = bookmark.customTags || []; const same = current.length === sanitizedTags.length && sanitizedTags.every((tag) => current.includes(tag)); if (same) { return { bookmarks, changed: false }; } bookmark.customTags = sanitizedTags; changed = true; return { bookmarks, changed: true }; }); if (!bookmarkFound) { showToast("未找到对应的收藏记录,无法更新标签", { isError: true }); return false; } if (conflictTag) { showToast(`标签 "${conflictTag}" 已存在于帖子标签中!`, { isError: true }); return false; } renderBookmarksTable(); if (changed) { renderTagFilters(); showToast("自定义标签已更新"); } return true; } function enterTagEditMode(row) { const tagCell = row.querySelector(`.${CONSTANTS.CLASSES.TAG_CELL}`); if (!tagCell || tagCell.querySelector(".tag-edit-mode")) return; const urlKey = row.dataset.urlKey; const bookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (!bookmark) { showToast("未找到对应的收藏记录,无法编辑标签", { isError: true }); return; } const originalTags = bookmark.tags || []; const customTags = bookmark.customTags || []; const originalTagSet = new Set(originalTags); const availableTags = new Set( getAllCustomTags().filter((tag) => !originalTagSet.has(tag)) ); customTags.forEach((tag) => availableTags.add(tag)); const selectedCustomTags = new Set(customTags); const editContainer = document.createElement("div"); editContainer.className = "tag-edit-mode"; if (originalTags.length > 0) { const originalLabel = document.createElement("span"); originalLabel.textContent = "帖子标签: "; originalLabel.style.fontSize = "12px"; originalLabel.style.color = "#666"; originalLabel.style.marginRight = "8px"; editContainer.appendChild(originalLabel); originalTags.forEach((tag) => { const pill = document.createElement("span"); pill.className = CONSTANTS.CLASSES.TAG_PILL; pill.textContent = tag; pill.style.backgroundColor = "#EFEFEF"; pill.style.color = "#555"; editContainer.appendChild(pill); }); editContainer.appendChild(document.createElement("br")); } const customHeader = document.createElement("div"); customHeader.className = "bm-custom-tag-header"; const customLabel = document.createElement("span"); customLabel.textContent = "自定义标签: "; customLabel.style.fontSize = "12px"; customLabel.style.color = "#1976D2"; customLabel.style.fontWeight = "bold"; customLabel.style.marginRight = "8px"; const dropdownWrapper = document.createElement("div"); dropdownWrapper.className = "bm-tag-dropdown"; const dropdownButton = document.createElement("button"); dropdownButton.type = "button"; dropdownButton.className = "bm-btn bm-tag-dropdown-toggle"; dropdownButton.textContent = "选择已有标签"; dropdownWrapper.appendChild(dropdownButton); const dropdownList = document.createElement("div"); dropdownList.className = "bm-tag-dropdown-list"; dropdownWrapper.appendChild(dropdownList); customHeader.appendChild(customLabel); customHeader.appendChild(dropdownWrapper); editContainer.appendChild(customHeader); const selectedContainer = document.createElement("div"); selectedContainer.className = "bm-selected-tags"; editContainer.appendChild(selectedContainer); const optionCheckboxMap = new Map(); const renderSelectedTags = () => { selectedContainer.innerHTML = ""; if (selectedCustomTags.size === 0) { const hint = document.createElement("span"); hint.className = "bm-tag-empty-hint"; hint.textContent = "尚未选择自定义标签"; selectedContainer.appendChild(hint); return; } Array.from(selectedCustomTags) .sort((a, b) => TAG_COLLATOR.compare(a, b)) .forEach((tag) => { const pill = document.createElement("span"); pill.className = "bm-selected-tag-pill"; pill.textContent = tag; const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = CONSTANTS.CLASSES.TAG_REMOVE_BTN; removeBtn.textContent = "×"; removeBtn.title = "移除该标签"; removeBtn.onclick = (event) => { event.stopPropagation(); selectedCustomTags.delete(tag); const checkbox = optionCheckboxMap.get(tag); if (checkbox) { checkbox.checked = false; } renderSelectedTags(); }; pill.appendChild(removeBtn); selectedContainer.appendChild(pill); }); }; const renderOptions = () => { optionCheckboxMap.clear(); dropdownList.innerHTML = ""; const sortedTags = Array.from(availableTags).sort((a, b) => TAG_COLLATOR.compare(a, b) ); if (sortedTags.length === 0) { const empty = document.createElement("span"); empty.className = "bm-tag-empty-hint"; empty.textContent = "暂无可复用标签"; dropdownList.appendChild(empty); return; } sortedTags.forEach((tag) => { const option = document.createElement("label"); option.className = "bm-tag-option"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.value = tag; checkbox.checked = selectedCustomTags.has(tag); checkbox.onchange = (event) => { event.stopPropagation(); if (checkbox.checked) { selectedCustomTags.add(tag); } else { selectedCustomTags.delete(tag); } renderSelectedTags(); }; const text = document.createElement("span"); text.textContent = tag; option.appendChild(checkbox); option.appendChild(text); option.addEventListener("click", (event) => event.stopPropagation()); dropdownList.appendChild(option); optionCheckboxMap.set(tag, checkbox); }); }; dropdownList.addEventListener("click", (event) => event.stopPropagation()); const handleOutsideClick = (event) => { if (!dropdownWrapper.contains(event.target)) { dropdownWrapper.classList.remove("open"); document.removeEventListener("click", handleOutsideClick); } }; dropdownButton.addEventListener("click", (event) => { event.stopPropagation(); const isOpen = dropdownWrapper.classList.toggle("open"); if (isOpen) { document.addEventListener("click", handleOutsideClick); } else { document.removeEventListener("click", handleOutsideClick); } }); const input = document.createElement("input"); input.className = CONSTANTS.IDS.TAG_EDIT_INPUT; input.type = "text"; input.placeholder = "输入新的自定义标签..."; editContainer.appendChild(input); const addBtn = document.createElement("button"); addBtn.className = `bm-btn ${CONSTANTS.CLASSES.TAG_ADD_BTN}`; addBtn.textContent = "添加"; const attemptAddNewTag = () => { const raw = input.value.trim(); if (!raw) { return; } if (originalTagSet.has(raw)) { showToast("该标签已存在于帖子标签中!", { isError: true }); return; } if (selectedCustomTags.has(raw)) { showToast("该自定义标签已存在!", { isError: true }); return; } selectedCustomTags.add(raw); availableTags.add(raw); input.value = ""; renderOptions(); renderSelectedTags(); }; addBtn.onclick = () => { attemptAddNewTag(); input.focus(); }; editContainer.appendChild(addBtn); const buttonCleanup = () => { document.removeEventListener("click", handleOutsideClick); dropdownWrapper.classList.remove("open"); }; const saveBtn = document.createElement("button"); saveBtn.className = `bm-btn ${CONSTANTS.CLASSES.TAG_SAVE_BTN}`; saveBtn.textContent = "完成"; saveBtn.onclick = () => { const success = setCustomTagsForBookmark( urlKey, Array.from(selectedCustomTags) ); if (success) { buttonCleanup(); } }; editContainer.appendChild(saveBtn); const cancelBtn = document.createElement("button"); cancelBtn.className = `bm-btn ${CONSTANTS.CLASSES.TAG_CANCEL_BTN}`; cancelBtn.textContent = "取消"; cancelBtn.onclick = () => { buttonCleanup(); exitTagEditMode(row); }; editContainer.appendChild(cancelBtn); tagCell.innerHTML = ""; tagCell.appendChild(editContainer); renderOptions(); renderSelectedTags(); input.focus(); input.onkeydown = (event) => { if (event.key === "Enter") { event.preventDefault(); attemptAddNewTag(); } if (event.key === "Escape") { event.preventDefault(); buttonCleanup(); exitTagEditMode(row); } }; } function exitTagEditMode(row) { renderBookmarksTable(); // 重新渲染表格以退出编辑模式 } function enterEditMode(row) { const nameCell = row.querySelector(".bm-name-cell"); if (nameCell.querySelector(`.${CONSTANTS.CLASSES.EDIT_INPUT}`)) return; // Already in edit mode const originalName = nameCell.textContent; nameCell.innerHTML = ``; const actionCell = row.querySelector(".bm-actions-cell"); const originalButtons = actionCell.innerHTML; actionCell.innerHTML = ``; const input = nameCell.querySelector(`.${CONSTANTS.CLASSES.EDIT_INPUT}`); input.focus(); input.select(); // 自动调整 textarea 高度 const autoResize = () => { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 120) + "px"; }; autoResize(); input.addEventListener("input", autoResize); const handleBlur = () => { saveRename(row, input.value); }; input.addEventListener("blur", handleBlur); const cancelAction = () => { input.removeEventListener("blur", handleBlur); input.removeEventListener("input", autoResize); nameCell.textContent = originalName; actionCell.innerHTML = originalButtons; }; const saveAction = () => { input.removeEventListener("blur", handleBlur); input.removeEventListener("input", autoResize); saveRename(row, input.value); }; row.querySelector(`.${CONSTANTS.CLASSES.SAVE_BTN}`).onclick = saveAction; row.querySelector(`.${CONSTANTS.CLASSES.CANCEL_BTN}`).onclick = cancelAction; input.onkeydown = (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); saveAction(); } if (e.key === "Escape") { cancelAction(); } }; } function saveRename(row, newName) { if (!newName.trim()) { showToast("名称不能为空!", { isError: true }); return; } const urlKey = row.dataset.urlKey; modifyBookmarks((bookmarks) => { const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (bookmark) bookmark.name = newName; return { bookmarks, changed: true }; }); renderBookmarksTable(); showToast("名称已更新!"); } function togglePinBookmark(row) { const urlKey = row.dataset.urlKey; let status = ""; modifyBookmarks((bookmarks) => { const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (bookmark) { bookmark.pinned = !bookmark.pinned; status = bookmark.pinned ? "置顶" : "取消置顶"; } return { bookmarks, changed: true }; }); showToast(`已${status}收藏`); renderBookmarksTable(); } function deleteBookmark(row) { const urlKey = row.dataset.urlKey; const allBookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const index = allBookmarks.findIndex( (b) => getRootTopicUrl(b.url) === urlKey ); if (index === -1) return; const item = allBookmarks[index]; // 从书签移除并移入回收站 modifyBookmarks((bookmarks) => { bookmarks.splice(index, 1); return { bookmarks, changed: true }; }); modifyTrash((trash) => { // 避免重复添加 const exists = trash.some((t) => getRootTopicUrl(t.url) === urlKey); if (!exists) trash.unshift({ ...item, deletedAt: Date.now() }); return { trash }; }); row.classList.add(CONSTANTS.CLASSES.ROW_HIDING); setTimeout(() => (row.style.display = "none"), 300); renderTagFilters(); showToast("已移入回收站", { actions: [ { text: "撤销", onClick: () => restoreFromTrash(urlKey, { showToastMsg: true }), }, ], }); } // [NEW] 从回收站恢复 function restoreFromTrash(urlKey, { showToastMsg = false } = {}) { const trash = GM_getValue(CONSTANTS.STORAGE_KEYS.TRASH, []); const idx = trash.findIndex((t) => getRootTopicUrl(t.url) === urlKey); if (idx === -1) return; const item = trash[idx]; modifyTrash((t) => { t.splice(idx, 1); return { trash: t }; }); modifyBookmarks((bookmarks) => { // 避免重复恢复 const exists = bookmarks.some((b) => getRootTopicUrl(b.url) === urlKey); if (!exists) bookmarks.unshift({ ...item, deletedAt: undefined }); return { bookmarks, changed: true }; }); renderBookmarksTable(); renderTagFilters(); if (showToastMsg) showToast("已恢复到收藏"); } // [NEW] 彻底删除单条 function purgeFromTrash(urlKey) { modifyTrash((trash) => { const idx = trash.findIndex((t) => getRootTopicUrl(t.url) === urlKey); if (idx !== -1) trash.splice(idx, 1); return { trash }; }); renderTrashTable(); showToast("已彻底删除"); } // [NEW] 清空回收站 function emptyTrash() { if (!confirm("确认清空回收站?此操作不可恢复!")) return; GM_setValue(CONSTANTS.STORAGE_KEYS.TRASH, []); renderTrashTable(); showToast("🧹 回收站已清空"); } function handleLocalImport(file) { const reader = new FileReader(); reader.onload = (e) => { try { const importedBookmarks = JSON.parse(e.target.result); if (!Array.isArray(importedBookmarks)) throw new Error("文件格式不正确。"); promptAndMergeBookmarks(importedBookmarks); } catch (error) { showToast("导入失败: " + error.message, { isError: true }); } finally { fileInput.value = ""; } }; reader.readAsText(file); } function handleLocalExport() { const bookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); if (bookmarks.length === 0) { showToast("没有收藏可以导出。", { isError: true }); return; } const dataStr = JSON.stringify(bookmarks, null, 2); const blob = new Blob([dataStr], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "linuxdo_bookmarks_backup.json"; a.click(); URL.revokeObjectURL(url); } // --- Part 4: WebDAV 核心功能 --- function getWebDAVConfig(fromStorage = true) { const server = fromStorage ? GM_getValue(CONSTANTS.STORAGE_KEYS.WEBDAV_SERVER) : getEl("webdav-server").value.trim(); const user = fromStorage ? GM_getValue(CONSTANTS.STORAGE_KEYS.WEBDAV_USER) : getEl("webdav-user").value.trim(); const pass = fromStorage ? GM_getValue(CONSTANTS.STORAGE_KEYS.WEBDAV_PASS) : getEl("webdav-pass").value; if (!server || !user || !pass) return null; return { server: server.endsWith("/") ? server : server + "/", user, pass }; } function saveWebDAVConfig() { const config = getWebDAVConfig(false); if (!config) { showToast("服务器、用户名和应用密码均不能为空!", { isError: true }); return; } GM_setValue(CONSTANTS.STORAGE_KEYS.WEBDAV_SERVER, config.server); GM_setValue(CONSTANTS.STORAGE_KEYS.WEBDAV_USER, config.user); GM_setValue(CONSTANTS.STORAGE_KEYS.WEBDAV_PASS, config.pass); GM_setValue( CONSTANTS.STORAGE_KEYS.AUTO_SYNC, getEl(CONSTANTS.IDS.AUTO_SYNC_TOGGLE).checked ); showToast("WebDAV 配置已保存!"); closeModal(webdavSettingsModal); } function webdavRequest(options) { const config = getWebDAVConfig(true); if (!config) { if (options.onerror) options.onerror({ status: 0, statusText: "WebDAV 配置不完整" }); else showToast("操作失败: WebDAV 配置不完整", { isError: true }); return; } GM_xmlhttpRequest({ method: options.method, url: config.server + (options.path || ""), headers: { Authorization: "Basic " + btoa(config.user + ":" + config.pass), ...options.headers, }, data: options.data, onload: options.onload, onerror: options.onerror, }); } function testWebDAVConnection() { const config = getWebDAVConfig(false); if (!config) { webdavTestResult.textContent = "请填写所有字段!"; return; } webdavTestResult.textContent = "正在测试连接..."; webdavRequest({ method: "PROPFIND", path: "", headers: { Depth: "0" }, onload: (res) => { if (res.status === 207 || res.status === 200) webdavTestResult.textContent = "连接成功!"; else if (res.status === 401) webdavTestResult.textContent = "连接失败: 用户名或密码错误 (401)"; else webdavTestResult.textContent = `连接失败: 服务器返回 ${res.status}`; }, onerror: () => (webdavTestResult.textContent = "连接失败: 请检查服务器地址或网络"), }); } function uploadToWebDAV(isAuto = false) { const filename = getTimestampedFilename(); const bookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); if (!isAuto) showToast(`正在手动备份到云端...`); const performPut = () => { webdavRequest({ method: "PUT", path: CONSTANTS.WEBDAV_DIR + filename, headers: { "Content-Type": "application/json; charset=utf-8" }, data: JSON.stringify(bookmarks, null, 2), onload: (res) => { if (res.status === 201 || res.status === 204) showToast(`${isAuto ? "自动" : "手动"}备份成功!`); else showToast(`备份失败: ${res.status} ${res.statusText}`, { isError: true, }); }, onerror: (res) => showToast(`备份出错: ${res.statusText}`, { isError: true }), }); }; webdavRequest({ method: "MKCOL", path: CONSTANTS.WEBDAV_DIR, onload: (res) => { if ([201, 405].includes(res.status)) performPut(); else showToast(`创建云端目录失败: ${res.status} ${res.statusText}`, { isError: true, }); }, onerror: (res) => showToast(`创建云端目录出错: ${res.statusText}`, { isError: true }), }); } function triggerAutoWebDAVSync() { if (GM_getValue(CONSTANTS.STORAGE_KEYS.AUTO_SYNC, false)) { uploadToWebDAV(true); } } function listWebDAVBackups() { openModal(webdavBrowserModal); webdavBrowserList.innerHTML = '
  • 正在加载备份列表...
  • '; webdavRequest({ method: "PROPFIND", path: CONSTANTS.WEBDAV_DIR, headers: { Depth: "1" }, onload: (res) => { if (res.status !== 207) { webdavBrowserList.innerHTML = `
  • 加载失败: ${res.statusText} (请确保目录已存在)
  • `; return; } const parser = new DOMParser(); const xmlDoc = parser.parseFromString( res.responseText, "application/xml" ); const files = Array.from(xmlDoc.getElementsByTagName("d:href")) .concat(Array.from(xmlDoc.getElementsByTagName("D:href"))) .map((node) => node.textContent.split("/").pop()) .filter( (name) => name.startsWith("linuxdo-backup-") && name.endsWith(".json") ) .sort() .reverse(); if (files.length === 0) { webdavBrowserList.innerHTML = '
  • 云端没有找到任何备份文件。
  • '; return; } webdavBrowserList.innerHTML = ""; files.forEach((file) => { const li = document.createElement("li"); li.className = "webdav-backup-item"; const nameSpan = document.createElement("span"); nameSpan.className = "webdav-backup-filename"; nameSpan.textContent = file; const deleteBtn = document.createElement("button"); deleteBtn.type = "button"; deleteBtn.className = "bm-btn bm-btn-danger webdav-delete-btn"; deleteBtn.textContent = "删除"; deleteBtn.onclick = (event) => { event.stopPropagation(); event.preventDefault(); deleteWebDAVBackup(file, li, deleteBtn); }; li.onclick = () => { if (deleteBtn.disabled) return; downloadFromWebDAV(file); }; li.appendChild(nameSpan); li.appendChild(deleteBtn); webdavBrowserList.appendChild(li); }); }, onerror: (res) => (webdavBrowserList.innerHTML = `
  • 加载出错: ${res.statusText}
  • `), }); } function downloadFromWebDAV(filename) { showToast(`即将从云端恢复备份: ${filename}`); webdavRequest({ method: "GET", path: CONSTANTS.WEBDAV_DIR + filename, onload: (res) => { if (res.status === 200) { try { const cloudBookmarks = JSON.parse(res.responseText); if (!Array.isArray(cloudBookmarks)) throw new Error("云端数据格式错误。"); closeModal(webdavBrowserModal); promptAndMergeBookmarks(cloudBookmarks); } catch (e) { showToast("解析云端数据失败!" + e.message, { isError: true }); } } else { showToast(`下载失败!服务器响应: ${res.status} ${res.statusText}`, { isError: true, }); } }, onerror: (res) => showToast(`下载出错!详情: ${res.statusText}`, { isError: true }), }); } function deleteWebDAVBackup(filename, listItem, deleteButton) { if (!confirm(`确定要删除云端备份文件 ${filename} 吗?`)) return; const originalLabel = deleteButton.textContent; deleteButton.textContent = "删除中..."; deleteButton.disabled = true; const finalizeSuccess = () => { listItem.remove(); if (!webdavBrowserList.querySelector("li")) { webdavBrowserList.innerHTML = '
  • 云端没有找到任何备份文件。
  • '; } showToast(`已删除云端备份: ${filename}`); }; const resetButton = () => { deleteButton.disabled = false; deleteButton.textContent = originalLabel; }; webdavRequest({ method: "DELETE", path: CONSTANTS.WEBDAV_DIR + filename, onload: (res) => { if ([200, 202, 204, 404].includes(res.status)) { finalizeSuccess(); } else { showToast(`删除失败: ${res.status} ${res.statusText}`, { isError: true, }); resetButton(); } }, onerror: (res) => { showToast(`删除出错: ${res.statusText}`, { isError: true }); resetButton(); }, }); } // --- Part 5: 通用逻辑与事件绑定 --- function promptAndMergeBookmarks(newBookmarks) { const choice = prompt( "请选择恢复模式:\n1. 增量合并 (智能去重)\n2. 完全覆盖 (清空本地后恢复)\n\n请输入数字 1 或 2" ); let dataChanged = false; modifyBookmarks((bookmarks) => { if (choice === "1") { const currentUrls = new Set( bookmarks.map((b) => getRootTopicUrl(b.url)) ); let addedCount = 0; newBookmarks.forEach((b) => { if (b.url && !currentUrls.has(getRootTopicUrl(b.url))) { bookmarks.unshift(b); addedCount++; } }); if (addedCount > 0) dataChanged = true; showToast( `合并完成!新增 ${addedCount} 条,跳过 ${newBookmarks.length - addedCount } 条。` ); } else if (choice === "2") { if (confirm("警告:此操作将清空您本地的所有收藏,确定要继续吗?")) { bookmarks = newBookmarks; dataChanged = true; showToast(`覆盖完成!成功恢复 ${newBookmarks.length} 条收藏。`); } } else { showToast("操作已取消。"); return false; } return { bookmarks, changed: dataChanged }; }); renderBookmarksTable(); } document.body.addEventListener("click", function (event) { // 处理可能的 SVG 或内部元素点击,找到真正的按钮元素 const target = event.target.closest('button') || event.target; if (target.classList.contains(CONSTANTS.CLASSES.TAG_FILTER_BTN)) { const tag = target.dataset.tag === "" ? null : target.dataset.tag; activeTagFilter = tag; currentPage = 1; // 切换标签时重置到第一页 renderTagFilters(); renderBookmarksTable(); return; } const row = target.closest("tr"); if (row && row.dataset.urlKey) { if (target.classList.contains(CONSTANTS.CLASSES.DELETE_BTN)) deleteBookmark(row); else if (target.classList.contains(CONSTANTS.CLASSES.RENAME_BTN)) enterEditMode(row); else if (target.classList.contains(CONSTANTS.CLASSES.TAG_EDIT_BTN)) enterTagEditMode(row); else if (target.classList.contains(CONSTANTS.CLASSES.PIN_BTN)) togglePinBookmark(row); else if (target.classList.contains(CONSTANTS.CLASSES.RESTORE_BTN)) restoreFromTrash(row.dataset.urlKey, { showToastMsg: true }); else if (target.classList.contains(CONSTANTS.CLASSES.PURGE_BTN)) purgeFromTrash(row.dataset.urlKey); return; } const buttonActions = { "manage-bookmarks-button": () => { setViewMode("bookmarks"); openModal(managerModal); }, [CONSTANTS.IDS.TRASH_BACK_BUTTON]: () => { setViewMode("bookmarks"); }, [CONSTANTS.IDS.SETTINGS_BUTTON]: () => { openModal(settingsModal); }, "webdav-settings-btn": () => { webdavTestResult.textContent = ""; getEl("webdav-server").value = GM_getValue( CONSTANTS.STORAGE_KEYS.WEBDAV_SERVER, "" ); getEl("webdav-user").value = GM_getValue( CONSTANTS.STORAGE_KEYS.WEBDAV_USER, "" ); getEl("webdav-pass").value = GM_getValue( CONSTANTS.STORAGE_KEYS.WEBDAV_PASS, "" ); autoSyncToggle.checked = GM_getValue( CONSTANTS.STORAGE_KEYS.AUTO_SYNC, false ); openModal(webdavSettingsModal); }, "save-webdav-settings": saveWebDAVConfig, "test-webdav-connection": testWebDAVConnection, "import-bookmarks-btn": () => fileInput.click(), "export-bookmarks-btn": handleLocalExport, "sync-from-cloud-btn": listWebDAVBackups, "sync-to-cloud-btn": () => uploadToWebDAV(false), [CONSTANTS.IDS.RENAME_TAGS_BUTTON]: () => startBulkRenameFlow(), [CONSTANTS.IDS.TRASH_TOGGLE_BUTTON]: () => { // 从配置页面进入回收站,需要先关闭配置页面,再打开主窗口显示回收站 closeModal(settingsModal); setViewMode("trash"); openModal(managerModal); }, [CONSTANTS.IDS.EMPTY_TRASH_BUTTON]: () => emptyTrash(), }; if (buttonActions[target.id]) buttonActions[target.id](); // 处理关闭按钮点击 if (target.classList.contains(CONSTANTS.CLASSES.CLOSE_BTN)) { const modalId = target.dataset.targetModal; if (modalId) { const modal = getEl(modalId); if (modal) { closeModal(modal); console.log(`关闭模态框: ${modalId}`); } else { console.error(`找不到模态框: ${modalId}`); } } } // 处理点击背景关闭模态框 if ( target.classList.contains(CONSTANTS.CLASSES.MODAL_BACKDROP) && !event.target.closest(`.${CONSTANTS.CLASSES.CONTENT_PANEL}`) ) closeModal(target); }); fileInput.addEventListener( "change", (e) => e.target.files[0] && handleLocalImport(e.target.files[0]) ); // 右键自定义标签快速重命名 document.body.addEventListener("contextmenu", (e) => { const pill = e.target.closest(`.${CONSTANTS.CLASSES.TAG_PILL}`); if ( pill && pill.parentElement && pill.parentElement.classList.contains(CONSTANTS.CLASSES.TAG_CELL) ) { // 仅对自定义标签启用(蓝色,有 editable 类 或 具有删除按钮) if (pill.classList.contains("editable")) { e.preventDefault(); const oldTag = pill.firstChild?.textContent || pill.textContent.replace(/×$/, "").trim(); if (oldTag) startBulkRenameFlow(oldTag); } } }); // 搜索框输入事件 const searchClearBtn = document.querySelector(".search-clear-btn"); searchInput.addEventListener("input", () => { currentPage = 1; // 搜索时重置到第一页 renderBookmarksTable(); // 显示/隐藏清除按钮 if (searchInput.value.trim()) { searchClearBtn.classList.add("visible"); } else { searchClearBtn.classList.remove("visible"); } }); // 清除按钮点击事件 searchClearBtn.addEventListener("click", () => { searchInput.value = ""; searchClearBtn.classList.remove("visible"); currentPage = 1; renderBookmarksTable(); searchInput.focus(); // 清除后聚焦到输入框 }); // 每页显示数量切换 pageSizeSelect.addEventListener("change", (e) => { itemsPerPage = parseInt(e.target.value, 10); currentPage = 1; // 切换每页数量时重置到第一页 renderBookmarksTable(); }); autoSyncToggle.addEventListener("change", (e) => GM_setValue(CONSTANTS.STORAGE_KEYS.AUTO_SYNC, e.target.checked) ); // --- Part 6: 页面按钮与初始化 --- const FLOATING_BUTTON_MARGIN = 12; const CLICK_MOVE_THRESHOLD = 3; function clamp(value, min, max) { if (!Number.isFinite(value)) return min; if (value < min) return min; if (value > max) return max; return value; } function applyFloatingButtonPosition(button, rightPercent, bottomPercent) { const width = button.offsetWidth || button.getBoundingClientRect().width || 0; const height = button.offsetHeight || button.getBoundingClientRect().height || 0; // 计算实际的right和bottom像素值 const rightPx = (window.innerWidth * rightPercent) / 100; const bottomPx = (window.innerHeight * bottomPercent) / 100; // 限制范围,确保按钮不会超出视口 const maxRight = Math.max(FLOATING_BUTTON_MARGIN, window.innerWidth - width - FLOATING_BUTTON_MARGIN); const maxBottom = Math.max(FLOATING_BUTTON_MARGIN, window.innerHeight - height - FLOATING_BUTTON_MARGIN); const clampedRight = clamp(rightPx, FLOATING_BUTTON_MARGIN, maxRight); const clampedBottom = clamp(bottomPx, FLOATING_BUTTON_MARGIN, maxBottom); button.style.right = `${clampedRight}px`; button.style.bottom = `${clampedBottom}px`; button.style.left = "auto"; button.style.top = "auto"; // 返回实际使用的百分比 return { rightPercent: (clampedRight / window.innerWidth) * 100, bottomPercent: (clampedBottom / window.innerHeight) * 100 }; } function loadSavedFloatingButtonPosition(button) { const savedPosition = GM_getValue( CONSTANTS.STORAGE_KEYS.FLOATING_BUTTON_POSITION, null ); // 如果没有保存的位置,使用默认位置:右侧5%,垂直居中50% let rightPercent = 5; let bottomPercent = 50; if (savedPosition && typeof savedPosition === "object") { // 兼容旧版本的left/top存储格式,转换为百分比 if (typeof savedPosition.left === "number" && typeof savedPosition.top === "number") { rightPercent = ((window.innerWidth - savedPosition.left) / window.innerWidth) * 100; bottomPercent = ((window.innerHeight - savedPosition.top) / window.innerHeight) * 100; } else if (typeof savedPosition.rightPercent === "number" && typeof savedPosition.bottomPercent === "number") { rightPercent = savedPosition.rightPercent; bottomPercent = savedPosition.bottomPercent; } } requestAnimationFrame(() => { const applied = applyFloatingButtonPosition(button, rightPercent, bottomPercent); // 如果位置被调整或格式需要更新,保存新的百分比位置 if (Math.abs(applied.rightPercent - rightPercent) > 0.1 || Math.abs(applied.bottomPercent - bottomPercent) > 0.1 || !savedPosition || savedPosition.left !== undefined) { GM_setValue( CONSTANTS.STORAGE_KEYS.FLOATING_BUTTON_POSITION, applied ); } }); } function persistFloatingButtonPosition(rightPx, bottomPx) { if (!Number.isFinite(rightPx) || !Number.isFinite(bottomPx)) return; // 将像素值转换为百分比保存 const rightPercent = (rightPx / window.innerWidth) * 100; const bottomPercent = (bottomPx / window.innerHeight) * 100; GM_setValue(CONSTANTS.STORAGE_KEYS.FLOATING_BUTTON_POSITION, { rightPercent, bottomPercent, }); } // 将创建和更新浮动按钮的逻辑封装成一个独立的函数 function updateFloatingButtons() { // a. 首先,移除可能已存在的旧按钮 const existingButton = document.getElementById(CONSTANTS.IDS.MANAGE_BUTTON); if (existingButton) { existingButton.remove(); } // b. 创建新的组合按钮 const floatingButton = document.createElement("button"); floatingButton.id = CONSTANTS.IDS.MANAGE_BUTTON; floatingButton.className = "action-button"; // c. 创建按钮内部结构 const bookmarksText = document.createElement("span"); bookmarksText.textContent = "收藏夹"; bookmarksText.style.cursor = "pointer"; const heartIcon = document.createElement("span"); heartIcon.className = "heart-icon"; let dragState = null; let suppressNextClick = false; const capturedClickHandler = (event) => { if (!suppressNextClick) return; suppressNextClick = false; event.stopImmediatePropagation(); event.preventDefault(); }; floatingButton.addEventListener("click", capturedClickHandler, true); // d. 检查是否在帖子页面 const isTopicPage = /\/(linux\.do|idcflare\.com)\/t\//.test(window.location.href); if (isTopicPage) { // 检查当前页面的收藏状态 const currentUrl = window.location.href; const currentUrlKey = getRootTopicUrl(currentUrl); const bookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const isBookmarked = bookmarks.some( (b) => getRootTopicUrl(b.url) === currentUrlKey ); // 设置心形图标样式 if (isBookmarked) { heartIcon.textContent = "♥"; heartIcon.className = "heart-icon filled"; } else { heartIcon.textContent = "♡"; heartIcon.className = "heart-icon empty"; } // 为心形图标绑定点击事件 heartIcon.addEventListener("click", (e) => { e.stopPropagation(); const bookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const currentUrlKey = getRootTopicUrl(window.location.href); const bookmarkIndex = bookmarks.findIndex( (b) => getRootTopicUrl(b.url) === currentUrlKey ); if (bookmarkIndex !== -1) { // 已收藏,执行取消收藏 const deletedItem = bookmarks[bookmarkIndex]; // 移到回收站 modifyTrash((trash) => { const exists = trash.some((t) => getRootTopicUrl(t.url) === currentUrlKey); if (!exists) trash.unshift({ ...deletedItem, deletedAt: Date.now() }); return { trash }; }); // 从书签中删除 modifyBookmarks((bookmarks) => { bookmarks.splice(bookmarkIndex, 1); return { bookmarks, changed: true }; }); // 更新图标状态 heartIcon.textContent = "♡"; heartIcon.className = "heart-icon empty"; } else { // 未收藏,执行收藏 const postUrl = window.location.href; const fullTitle = document.title.replace(/\s*-\s*(?:LINUX\s*DO|IDC\s*Flare)\s*$/i, ""); let cleanTitle = fullTitle; let tags = []; const tagMatch = fullTitle.match(/\s*-\s*([^\-]+)$/); if (tagMatch && tagMatch[1]) { const rawTagString = tagMatch[1].trim(); const primaryTag = rawTagString.split(/[\/,]/)[0].trim(); tags.push(primaryTag); cleanTitle = fullTitle.replace(tagMatch[0], "").trim(); } modifyBookmarks((bookmarks) => { bookmarks.unshift({ name: cleanTitle, url: postUrl, pinned: false, tags: tags, timestamp: Date.now(), // 添加收藏时间戳 }); return { bookmarks, changed: true }; }); // 更新图标状态 heartIcon.textContent = "♥"; heartIcon.className = "heart-icon filled"; } }); // 组装按钮结构(带心形图标) floatingButton.appendChild(bookmarksText); floatingButton.appendChild(heartIcon); } else { // 非帖子页面,只显示收藏夹文本 floatingButton.appendChild(bookmarksText); } // e. 使用 pointer 事件统一处理拖拽和轻击打开逻辑 floatingButton.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; if (event.target === heartIcon) return; suppressNextClick = false; const initialRect = floatingButton.getBoundingClientRect(); const computedStyle = window.getComputedStyle(floatingButton); // 确保按钮使用right/bottom定位 if (computedStyle.right === "auto" || computedStyle.bottom === "auto") { floatingButton.style.right = `${window.innerWidth - initialRect.right}px`; floatingButton.style.bottom = `${window.innerHeight - initialRect.bottom}px`; floatingButton.style.left = "auto"; floatingButton.style.top = "auto"; } const rect = floatingButton.getBoundingClientRect(); dragState = { pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, startRight: window.innerWidth - rect.right, startBottom: window.innerHeight - rect.bottom, moved: false, latestRight: window.innerWidth - rect.right, latestBottom: window.innerHeight - rect.bottom, initialTarget: event.target, }; floatingButton.setPointerCapture(event.pointerId); }); floatingButton.addEventListener("pointermove", (event) => { if (!dragState || event.pointerId !== dragState.pointerId) return; const deltaX = event.clientX - dragState.startClientX; const deltaY = event.clientY - dragState.startClientY; if (!dragState.moved) { if (Math.abs(deltaX) + Math.abs(deltaY) < CLICK_MOVE_THRESHOLD) { return; } dragState.moved = true; floatingButton.classList.add("dragging"); } // 注意:向右拖动时deltaX为正,但right应该减小;向下拖动时deltaY为正,但bottom应该减小 const proposedRight = dragState.startRight - deltaX; const proposedBottom = dragState.startBottom - deltaY; // 使用百分比计算 const proposedRightPercent = (proposedRight / window.innerWidth) * 100; const proposedBottomPercent = (proposedBottom / window.innerHeight) * 100; const applied = applyFloatingButtonPosition( floatingButton, proposedRightPercent, proposedBottomPercent ); // 保存当前的像素位置用于后续计算 dragState.latestRight = (applied.rightPercent / 100) * window.innerWidth; dragState.latestBottom = (applied.bottomPercent / 100) * window.innerHeight; }); const finishPointerInteraction = (event, shouldTreatAsClick) => { if (!dragState || event.pointerId !== dragState.pointerId) return; floatingButton.releasePointerCapture(event.pointerId); floatingButton.classList.remove("dragging"); if (dragState.moved) { suppressNextClick = true; event.preventDefault(); event.stopPropagation(); persistFloatingButtonPosition(dragState.latestRight, dragState.latestBottom); } else if (shouldTreatAsClick) { suppressNextClick = true; event.stopPropagation(); const managerModal = document.getElementById(CONSTANTS.IDS.MANAGER_MODAL); if (managerModal) { setViewMode("bookmarks"); openModal(managerModal); } } dragState = null; if (suppressNextClick) { setTimeout(() => { suppressNextClick = false; }, 0); } }; floatingButton.addEventListener("pointerup", (event) => { finishPointerInteraction(event, true); }); floatingButton.addEventListener("pointercancel", (event) => { finishPointerInteraction(event, false); }); document.body.appendChild(floatingButton); loadSavedFloatingButtonPosition(floatingButton); } // 页面首次加载时,立即执行一次函数来创建按钮 updateFloatingButtons(); window.addEventListener("resize", () => { const button = document.getElementById(CONSTANTS.IDS.MANAGE_BUTTON); if (!button) return; const saved = GM_getValue( CONSTANTS.STORAGE_KEYS.FLOATING_BUTTON_POSITION, null ); if (!saved || typeof saved !== "object") return; // 支持新旧两种存储格式 let rightPercent, bottomPercent; if (typeof saved.rightPercent === "number" && typeof saved.bottomPercent === "number") { rightPercent = saved.rightPercent; bottomPercent = saved.bottomPercent; } else if (typeof saved.left === "number" && typeof saved.top === "number") { // 兼容旧格式,转换为百分比 rightPercent = ((window.innerWidth - saved.left) / window.innerWidth) * 100; bottomPercent = ((window.innerHeight - saved.top) / window.innerHeight) * 100; } else { return; } const applied = applyFloatingButtonPosition(button, rightPercent, bottomPercent); if (Math.abs(applied.rightPercent - rightPercent) > 0.1 || Math.abs(applied.bottomPercent - bottomPercent) > 0.1) { GM_setValue(CONSTANTS.STORAGE_KEYS.FLOATING_BUTTON_POSITION, applied); } }); // 创建一个 MutationObserver 来监听页面标题的变化 // 这是修复 SPA 页面切换 bug 的核心 const observer = new MutationObserver(() => { // 当监听到变化(意味着可能切换了帖子),就重新调用函数来更新按钮状态 // 使用 setTimeout 做一个小的延迟,确保页面其他部分也已加载完毕 setTimeout(updateFloatingButtons, 200); }); // 让观察者开始监视 元素的变化 const titleElement = document.querySelector("title"); if (titleElement) { observer.observe(titleElement, { childList: true }); } console.log("L站收藏夹已加载!"); // 视图切换 function setViewMode(mode) { viewMode = mode; const header = document.getElementById("bm-header-title"); activeTagFilter = null; currentPage = 1; // 切换视图时重置页码 if (trashBackButton) { trashBackButton.style.display = viewMode === "trash" ? "inline-flex" : "none"; } if (viewMode === "trash") { header && (header.textContent = "回收站"); } else { header && (header.textContent = "收藏夹"); } renderTagFilters(); renderBookmarksTable(); } })();