// ==UserScript==
// @name linux.do 收藏夹
// @namespace http://tampermonkey.net/
// @version 6.3.2
// @description 收藏 linux.do 的帖子。
// @match https://linux.do/*
// @exclude https://linux.do/a/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect *
// ==/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,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="%23F57C00"/></svg>');
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,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="%23F57C00"/></svg>');
background-size: contain;
background-repeat: no-repeat;
vertical-align: middle;
position: relative;
top: -2px;
}
}
`);
document.body.insertAdjacentHTML(
"beforeend",
`
<div id="${CONSTANTS.IDS.MANAGER_MODAL}" class="${CONSTANTS.CLASSES.MODAL_BACKDROP}">
<div class="bm-content-panel">
<div class="bm-header">
<h2 id="bm-header-title">收藏夹</h2>
<div class="bm-header-actions">
<button id="${CONSTANTS.IDS.TRASH_BACK_BUTTON}" class="bm-settings-btn" aria-label="返回收藏夹" title="返回收藏夹" style="display:none;">
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M14.5 3H7.71l-.85-.85L6.51 2h-5l-.5.5v11l.5.5h13l.5-.5v-10L14.5 3zm-.51 8.49V13h-12V7h4.49l.35-.15.86-.86H14v1.5l-.01 4zm0-6.49h-6.5l-.35.15-.86.86H2v-3h4.29l.85.85.36.15H14l-.01.99z"/>
</svg>
</button>
<button id="${CONSTANTS.IDS.SETTINGS_BUTTON}" class="bm-settings-btn" aria-label="打开配置">
<svg class="bm-settings-icon" viewBox="0 0 72 72" aria-hidden="true" focusable="false">
<g>
<g>
<path fill="currentColor" d="M35.649,45.188c-5.066,0-9.188-4.121-9.188-9.188c0-5.066,4.121-9.188,9.188-9.188c5.065,0,9.188,4.122,9.188,9.188 C44.837,41.067,40.715,45.188,35.649,45.188z M35.649,30.812c-2.86,0-5.188,2.327-5.188,5.188s2.327,5.188,5.188,5.188 s5.188-2.327,5.188-5.188S38.51,30.812,35.649,30.812z"/>
</g>
<g>
<path fill="currentColor" d="M35.913,68.5H35.72c-3.65,0-6.958-2.748-8.125-6.572c-1.561-0.485-3.078-1.115-4.536-1.881 c-1.498,0.842-3.155,1.297-4.777,1.297c-2.091,0-4.015-0.773-5.418-2.177l-0.172-0.182c-2.541-2.54-2.924-6.823-1.034-10.357 c-0.752-1.44-1.274-2.976-1.768-4.585c-3.83-1.157-6.39-4.326-6.39-7.92v-0.193c0-3.607,2.558-6.795,6.384-7.966 c0.519-1.718,1.103-3.29,1.876-4.755c-1.943-3.501-1.652-7.604,0.887-10.144l0.126-0.139c1.462-1.462,3.494-2.269,5.731-2.269 c1.628,0,3.261,0.424,4.717,1.211c1.364-0.705,2.781-1.292,4.236-1.753C28.459,6.262,31.806,3.5,35.72,3.5h0.193 c3.859,0,7.03,2.714,7.96,6.625c1.474,0.469,2.909,1.065,4.288,1.783c1.399-0.82,2.991-1.262,4.597-1.262 c2.262,0,4.368,0.86,5.931,2.422l0.103,0.095c2.772,2.771,3.104,6.936,1.002,10.354c0.73,1.394,1.436,2.813,1.897,4.242 c3.905,0.924,6.81,4.232,6.81,8.171v0.193c0,3.918-2.906,7.207-6.815,8.121c-0.438,1.333-1.068,2.691-1.781,4.062 c2.063,3.443,1.637,7.79-1.157,10.585l-0.146,0.213C57.103,60.6,55.098,61.5,52.983,61.5h0.002c-1.601,0-3.214-0.549-4.656-1.42 c-1.47,0.779-3.003,1.38-4.583,1.873C42.663,65.838,39.531,68.5,35.913,68.5z M22.983,55.719c0.349,0,0.697,0.09,1.01,0.273 c1.821,1.064,3.767,1.873,5.78,2.4c0.76,0.198,1.333,0.824,1.465,1.598c0.431,2.529,2.399,4.51,4.481,4.51h0.193 c2.282,0,3.818-2.252,4.155-4.477c0.119-0.79,0.697-1.433,1.47-1.635c2.061-0.541,4.042-1.37,5.891-2.465 c0.721-0.428,1.632-0.359,2.282,0.169c0.989,0.806,2.153,1.249,3.277,1.25l0,0c1.062,0,2.019-0.396,2.768-1.145l0.133-0.134 c1.654-1.654,1.683-4.389,0.066-6.361c-0.524-0.641-0.6-1.539-0.189-2.258c1.075-1.887,1.856-3.732,2.323-5.488 c0.217-0.817,1.029-1.41,1.872-1.48c2.569-0.213,4.539-2.045,4.539-4.354v-0.193c0-2.336-1.971-4.189-4.542-4.408 c-0.841-0.071-1.6-0.663-1.816-1.479c-0.504-1.899-1.333-3.802-2.41-5.654c-0.421-0.724-0.359-1.633,0.174-2.279 c1.648-1.997,1.688-4.496,0.106-6.078l-0.104-0.095c-0.85-0.848-1.954-1.292-3.147-1.292c-1.151,0-2.298,0.414-3.229,1.164 c-0.645,0.52-1.544,0.589-2.261,0.172c-1.784-1.038-3.692-1.83-5.671-2.355c-0.809-0.214-1.399-0.909-1.479-1.742 C39.871,9.303,38.142,7.5,35.913,7.5H35.72c-2.313,0-4.264,1.895-4.535,4.407c-0.089,0.823-0.676,1.506-1.477,1.719 c-1.935,0.512-3.806,1.283-5.562,2.291c-0.68,0.392-1.528,0.348-2.165-0.112c-1.011-0.729-2.28-1.146-3.482-1.146 c-1.169,0-2.203,0.391-2.912,1.1l-0.14,0.139c-1.6,1.6-1.107,4.267,0.206,6.074c0.471,0.647,0.511,1.513,0.101,2.2 c-1.046,1.755-1.841,3.707-2.431,5.965c-0.197,0.755-0.71,1.326-1.479,1.463C9.308,32.052,7.5,33.914,7.5,35.931v0.193 c0,1.996,1.806,3.835,4.34,4.279c0.771,0.135,1.339,0.707,1.537,1.465c0.547,2.094,1.319,4.047,2.348,5.805 c0.397,0.678,0.347,1.527-0.109,2.168c-1.491,2.098-1.515,4.893-0.045,6.362l0.168,0.181c0.787,0.786,1.821,0.961,2.544,0.961 c1.171,0,2.452-0.451,3.515-1.235C22.149,55.85,22.565,55.719,22.983,55.719z"/>
</g>
<g>
<g>
<path fill="currentColor" d="M52.704,30.489c-0.402,0-0.781-0.244-0.934-0.642c-2.54-6.612-9.007-11.054-16.092-11.055c-0.553,0-1-0.448-1-1 s0.447-1,1-1l0,0c7.906,0,15.124,4.958,17.959,12.338c0.197,0.515-0.06,1.094-0.575,1.292 C52.944,30.468,52.823,30.489,52.704,30.489z"/>
</g>
<g>
<path fill="currentColor" d="M18.936,44.918c-0.339,0-0.67-0.173-0.857-0.484c-0.199-0.33-0.288-0.574-0.448-1.02 c-0.077-0.211-0.175-0.483-0.318-0.857c-0.198-0.516,0.06-1.094,0.575-1.291c0.515-0.199,1.094,0.06,1.292,0.574 c0.149,0.39,0.252,0.674,0.331,0.894c0.146,0.401,0.188,0.513,0.281,0.667c0.285,0.474,0.133,1.088-0.34,1.373 C19.289,44.872,19.111,44.918,18.936,44.918z"/>
</g>
<g>
<path fill="currentColor" d="M35.271,54.895L35.271,54.895c-5.683,0-11.045-2.494-14.711-6.843c-0.356-0.423-0.303-1.054,0.12-1.409 c0.421-0.356,1.053-0.303,1.409,0.12c3.285,3.896,8.09,6.132,13.182,6.132c0.552,0,1,0.448,1,1 C36.271,54.448,35.823,54.895,35.271,54.895z"/>
</g>
</g>
</g>
</svg>
</button>
<button type="button" class="${CONSTANTS.CLASSES.CLOSE_BTN}" data-target-modal="${CONSTANTS.IDS.MANAGER_MODAL}" aria-label="关闭">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
</svg>
</button>
</div>
</div>
<div id="${CONSTANTS.IDS.TAG_FILTER_CONTAINER}"></div>
<div id="${CONSTANTS.IDS.TABLE_CONTAINER}"></div>
<div id="${CONSTANTS.IDS.FOOTER_CONTAINER}">
<div class="footer-left">
<label for="${CONSTANTS.IDS.SEARCH_INPUT}" class="search-label">Search:</label>
<div class="search-input-container">
<input type="text" id="${CONSTANTS.IDS.SEARCH_INPUT}" placeholder="搜索...">
<button class="search-clear-btn" aria-label="清除搜索">✕</button>
</div>
</div>
<div class="footer-right">
<div id="${CONSTANTS.IDS.PAGINATION_INFO}"></div>
<div id="${CONSTANTS.IDS.PAGINATION_CONTROLS}"></div>
<div class="footer-show">
<label for="${CONSTANTS.IDS.PAGE_SIZE_SELECT}" class="show-label">Show:</label>
<select id="${CONSTANTS.IDS.PAGE_SIZE_SELECT}">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div id="${CONSTANTS.IDS.SETTINGS_MODAL}" class="${CONSTANTS.CLASSES.MODAL_BACKDROP}">
<div class="bm-content-panel" style="max-width: 800px;">
<div class="bm-header">
<h2>配置与管理</h2>
<button type="button" class="${CONSTANTS.CLASSES.CLOSE_BTN}" data-target-modal="${CONSTANTS.IDS.SETTINGS_MODAL}" aria-label="关闭">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
</svg>
</button>
</div>
<div class="settings-sections">
<div class="settings-section">
<h3>数据管理</h3>
<div class="settings-buttons">
<button id="import-bookmarks-btn" class="bm-btn bm-btn-io">导入收藏</button>
<button id="export-bookmarks-btn" class="bm-btn bm-btn-io">导出收藏</button>
</div>
<p class="settings-desc">从本地文件导入或导出收藏数据</p>
</div>
<div class="settings-section">
<h3>云端同步</h3>
<div class="settings-buttons">
<button id="sync-from-cloud-btn" class="bm-btn bm-btn-cloud">恢复</button>
<button id="sync-to-cloud-btn" class="bm-btn bm-btn-cloud">备份</button>
<button id="webdav-settings-btn" class="bm-btn">云同步设置</button>
</div>
<p class="settings-desc">通过 WebDAV 在多设备间同步收藏</p>
</div>
<div class="settings-section">
<h3>标签管理</h3>
<div class="settings-buttons">
<button id="${CONSTANTS.IDS.RENAME_TAGS_BUTTON}" class="bm-btn">批量重命名标签</button>
</div>
<p class="settings-desc">批量重命名自定义标签</p>
</div>
<div class="settings-section">
<h3>回收站</h3>
<div class="settings-buttons">
<button id="${CONSTANTS.IDS.TRASH_TOGGLE_BUTTON}" class="bm-btn bm-btn-danger">查看回收站</button>
<button id="${CONSTANTS.IDS.EMPTY_TRASH_BUTTON}" class="bm-btn bm-btn-danger">清空回收站</button>
</div>
<p class="settings-desc">查看已删除的收藏或清空回收站</p>
</div>
</div>
</div>
</div>
<div id="${CONSTANTS.IDS.WEBDAV_SETTINGS_MODAL}" class="${CONSTANTS.CLASSES.MODAL_BACKDROP}">
<div class="bm-content-panel">
<div class="bm-header">
<h2>WebDAV 云同步设置</h2>
<button type="button" class="${CONSTANTS.CLASSES.CLOSE_BTN}" data-target-modal="${CONSTANTS.IDS.WEBDAV_SETTINGS_MODAL}" aria-label="关闭">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
</svg>
</button>
</div>
<div class="webdav-form-group"><label for="webdav-server">服务器地址:</label><input type="text" id="webdav-server" class="webdav-input" placeholder="例如: https://dav.jianguoyun.com/dav/"></div>
<div class="webdav-form-group"><label for="webdav-user">用户名:</label><input type="text" id="webdav-user" class="webdav-input"></div>
<div class="webdav-form-group"><label for="webdav-pass">应用密码 (非登录密码):</label><input type="password" id="webdav-pass" class="webdav-input"></div>
<div class="webdav-form-group"><label><input type="checkbox" id="${CONSTANTS.IDS.AUTO_SYNC_TOGGLE}">当收藏变化时自动备份</label></div>
<div class="webdav-footer">
<div id="${CONSTANTS.IDS.WEBDAV_TEST_RESULT}"></div>
<div class="webdav-footer-buttons"><button id="test-webdav-connection" class="bm-btn">测试连接</button><button id="save-webdav-settings" class="bm-btn bm-btn-io">保存</button></div>
</div>
</div>
</div>
<div id="${CONSTANTS.IDS.WEBDAV_BROWSER_MODAL}" class="${CONSTANTS.CLASSES.MODAL_BACKDROP}">
<div class="bm-content-panel">
<div class="bm-header">
<h2>选择一个云端备份进行恢复</h2>
<button type="button" class="${CONSTANTS.CLASSES.CLOSE_BTN}" data-target-modal="${CONSTANTS.IDS.WEBDAV_BROWSER_MODAL}" aria-label="关闭">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
</svg>
</button>
</div>
<ul id="${CONSTANTS.IDS.WEBDAV_BROWSER_LIST}"><li class="loading-text">正在加载备份列表...</li></ul>
</div>
</div>
<template id="${CONSTANTS.IDS.ROW_TEMPLATE}">
<tr data-url-key="">
<td class="bm-name-cell" data-label="名称"></td>
<td class="bm-url-cell" data-label="链接"><a href="" target="_blank" title=""></a></td>
<td class="${CONSTANTS.CLASSES.TAG_CELL}" data-label="标签"></td>
<td class="bm-time-cell" data-label="时间"></td>
<td class="bm-actions-cell" data-label="操作" style="text-align:center; white-space:nowrap;">
<button class="bm-btn bm-btn-pin ${CONSTANTS.CLASSES.PIN_BTN}">置顶</button>
<button class="bm-btn ${CONSTANTS.CLASSES.RENAME_BTN}">重命名</button>
<button class="bm-btn ${CONSTANTS.CLASSES.TAG_EDIT_BTN}">编辑标签</button>
<button class="bm-btn bm-btn-danger ${CONSTANTS.CLASSES.DELETE_BTN}">删除</button>
</td>
</tr>
</template>
`
);
// --- 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\/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 =
'<p style="text-align:center; color:#888; padding:20px 0;">没有找到匹配的收藏。</p>';
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 = `<thead><tr><th style="width: 31%;">名称</th><th style="width: 20%;">链接</th><th style="width: 16%;">标签</th><th style="width: 11%;">收藏时间</th><th style="width: 22%; text-align:center;">操作</th></tr></thead>`;
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 =
'<p style="text-align:center; color:#888; padding:20px 0;">回收站为空或没有匹配项。</p>';
return;
}
const table = document.createElement("table");
table.id = CONSTANTS.IDS.TABLE;
table.innerHTML = `<thead><tr><th style="width: 31%;">名称</th><th style="width: 23%;">链接</th><th style="width: 12%;">标签</th><th style="width: 12%;">收藏时间</th><th style="width: 22%; text-align:center;">操作</th></tr></thead>`;
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 = `<textarea class="${CONSTANTS.CLASSES.EDIT_INPUT}" style="width:100%; min-height:40px; max-height:120px; padding:8px; border-radius:4px; border:1px solid #DDD; font-family:inherit; font-size:inherit; resize:vertical; overflow-y:auto; line-height:1.4;">${originalName}</textarea>`;
const actionCell = row.querySelector(".bm-actions-cell");
const originalButtons = actionCell.innerHTML;
actionCell.innerHTML = `<button class="bm-btn ${CONSTANTS.CLASSES.SAVE_BTN}">保存</button><button class="bm-btn ${CONSTANTS.CLASSES.CANCEL_BTN}">取消</button>`;
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, 409].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 =
'<li class="loading-text">正在加载备份列表...</li>';
webdavRequest({
method: "PROPFIND",
path: CONSTANTS.WEBDAV_DIR,
headers: { Depth: "1" },
onload: (res) => {
if (res.status !== 207) {
webdavBrowserList.innerHTML = `<li class="loading-text">加载失败: ${res.statusText} (请确保目录已存在)</li>`;
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 =
'<li class="loading-text">云端没有找到任何备份文件。</li>';
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 = `<li class="loading-text">加载出错: ${res.statusText}</li>`),
});
}
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 =
'<li class="loading-text">云端没有找到任何备份文件。</li>';
}
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 = window.location.href.includes("linux.do/t/");
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\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);
});
// 让观察者开始监视 <title> 元素的变化
const titleElement = document.querySelector("title");
if (titleElement) {
observer.observe(titleElement, { childList: true });
}
console.log("超级收藏夹 (v6.0 简化UI版) 已加载!");
// 视图切换
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();
}
})();