Bookmarker

Manually append the current page title and URL to a local Markdown bookmark file.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Bookmarker
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  Manually append the current page title and URL to a local Markdown bookmark file.
// @author       Xiang0731
// @license      MIT
// @match        *://*/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    if (window.top !== window.self) {
        return;
    }

    const APP_ID = 'bookmarker';
    const ROOT_ID = `${APP_ID}-root`;
    const PANEL_ID = `${APP_ID}-panel`;
    const REMARK_DIALOG_ID = `${APP_ID}-remark-dialog`;
    const REMARK_HINT_ID = `${APP_ID}-remark-hint`;
    const REMARK_INPUT_ID = `${APP_ID}-remark-input`;
    const REMARK_SAVE_ID = `${APP_ID}-remark-save`;
    const REMARK_CANCEL_ID = `${APP_ID}-remark-cancel`;
    const TOGGLE_ID = `${APP_ID}-toggle`;
    const TITLE_ID = `${APP_ID}-title`;
    const FILE_ID = `${APP_ID}-file`;
    const STATUS_ID = `${APP_ID}-status`;
    const SAVE_ID = `${APP_ID}-save`;
    const SAVE_WITH_REMARK_ID = `${APP_ID}-save-with-remark`;
    const PICK_ID = `${APP_ID}-pick`;
    const DB_NAME = 'bookmarker-db';
    const STORE_NAME = 'handles';
    const HANDLE_KEY = 'bookmark-markdown-file';
    const BACKUP_DIRECTORY_NAME = 'bookmarker-backups';
    const TABLE_HEADER_ROW = '| No | 标题 | 链接 | remark |';
    const TABLE_DIVIDER_ROW = '| --- | --- | --- | --- |';

    let isBusy = false;
    let isPanelOpen = false;
    let isRemarkDialogOpen = false;
    let remarkDraftContext = null;

    function normalizeText(value) {
        return String(value || '')
            .replace(/\s+/g, ' ')
            .trim();
    }

    function getMetaContent(selector) {
        const node = document.querySelector(selector);
        return normalizeText(node?.content || '');
    }

    function getPageTitle() {
        const documentTitle = normalizeText(document.title);
        const metaTitle =
            getMetaContent('meta[property="og:title"]') ||
            getMetaContent('meta[name="twitter:title"]');
        const headingTitle = normalizeText(document.querySelector('h1')?.textContent || '');

        return documentTitle || metaTitle || headingTitle || location.hostname || 'Untitled';
    }

    function normalizeUrl(value) {
        const raw = normalizeText(value);
        if (!raw) {
            return '';
        }

        try {
            const url = new URL(raw, location.href);
            url.hash = '';
            return url.href;
        } catch (error) {
            return raw;
        }
    }

    function escapeMarkdownTableText(value) {
        return normalizeText(value)
            .replace(/\\/g, '\\\\')
            .replace(/\|/g, '\\|');
    }

    function unescapeMarkdownTableText(value) {
        const text = String(value || '');
        let result = '';

        for (let i = 0; i < text.length; i += 1) {
            const current = text[i];
            const next = text[i + 1];

            if (current === '\\' && (next === '\\' || next === '|')) {
                result += next;
                i += 1;
                continue;
            }

            result += current;
        }

        return result.trim();
    }

    function isEscapedCharacter(text, index) {
        let slashCount = 0;

        for (let i = index - 1; i >= 0 && text[i] === '\\'; i -= 1) {
            slashCount += 1;
        }

        return slashCount % 2 === 1;
    }

    function parseMarkdownTableLine(line) {
        const trimmed = String(line || '').trim();
        if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) {
            return null;
        }

        const cells = [];
        let buffer = '';

        for (let i = 1; i < trimmed.length - 1; i += 1) {
            const char = trimmed[i];
            if (char === '|' && !isEscapedCharacter(trimmed, i)) {
                cells.push(buffer.trim());
                buffer = '';
                continue;
            }

            buffer += char;
        }

        cells.push(buffer.trim());
        return cells;
    }

    function extractUrlFromTableCell(value) {
        const text = normalizeText(value);
        if (!text) {
            return '';
        }

        const angleMatch = text.match(/^<(.+)>$/);
        if (angleMatch?.[1]) {
            return normalizeUrl(angleMatch[1]);
        }

        const markdownLinkMatch = text.match(/\((?:<)?([^()\s>]+)(?:>)?\)$/);
        if (markdownLinkMatch?.[1]) {
            return normalizeUrl(markdownLinkMatch[1]);
        }

        return normalizeUrl(text);
    }

    function countTrailingNewlines(text) {
        const match = String(text || '').match(/\n*$/);
        return match ? match[0].length : 0;
    }

    function buildBackupTimestamp(date) {
        const current = date instanceof Date ? date : new Date();
        const parts = [
            current.getFullYear(),
            String(current.getMonth() + 1).padStart(2, '0'),
            String(current.getDate()).padStart(2, '0'),
            '-',
            String(current.getHours()).padStart(2, '0'),
            String(current.getMinutes()).padStart(2, '0'),
            String(current.getSeconds()).padStart(2, '0')
        ];

        return parts.join('');
    }

    function sanitizeBackupBaseName(name) {
        const normalized = normalizeText(name || 'bookmarks.md');
        return normalized
            .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
            .replace(/\s+/g, '_');
    }

    function buildBackupFileName(sourceName) {
        const safeName = sanitizeBackupBaseName(sourceName);
        const match = safeName.match(/^(.*?)(\.[^.]+)?$/);
        const base = match?.[1] || 'bookmarks';
        const ext = match?.[2] || '.md';

        return `${base}.${buildBackupTimestamp(new Date())}.bak${ext}`;
    }

    function isBookmarkTableHeader(cells) {
        if (!Array.isArray(cells) || cells.length !== 4) {
            return false;
        }

        return (
            cells[0] === 'No' &&
            cells[1] === '标题' &&
            cells[2] === '链接' &&
            cells[3].toLowerCase() === 'remark'
        );
    }

    function isMarkdownDividerRow(cells) {
        return (
            Array.isArray(cells) &&
            cells.length === 4 &&
            cells.every((cell) => /^:?-{3,}:?$/.test(cell))
        );
    }

    function findBookmarkTables(lines) {
        const tables = [];

        for (let i = 0; i < lines.length - 1; i += 1) {
            const headerCells = parseMarkdownTableLine(lines[i]);
            const dividerCells = parseMarkdownTableLine(lines[i + 1]);

            if (!isBookmarkTableHeader(headerCells) || !isMarkdownDividerRow(dividerCells)) {
                continue;
            }

            let endIndex = i + 1;
            for (let j = i + 2; j < lines.length; j += 1) {
                const rowCells = parseMarkdownTableLine(lines[j]);
                if (!rowCells) {
                    break;
                }

                endIndex = j;
            }

            tables.push({
                headerIndex: i,
                dividerIndex: i + 1,
                endIndex
            });

            i = endIndex;
        }

        return tables;
    }

    function findBookmarkEntryByUrl(lines, targetUrl) {
        const tableRanges = findBookmarkTables(lines);
        const lastTableRange = tableRanges.length > 0 ? tableRanges[tableRanges.length - 1] : null;

        if (!targetUrl) {
            return {
                tableRange: lastTableRange,
                entry: null
            };
        }

        for (const tableRange of tableRanges) {
            for (let i = tableRange.dividerIndex + 1; i <= tableRange.endIndex; i += 1) {
                const cells = parseMarkdownTableLine(lines[i]);
                if (!cells || cells.length < 4 || !/^\d+$/.test(cells[0])) {
                    continue;
                }

                const rowUrl = extractUrlFromTableCell(cells[2]);
                if (rowUrl !== targetUrl) {
                    continue;
                }

                return {
                    tableRange,
                    entry: {
                        lineIndex: i,
                        no: Number(cells[0]),
                        title: unescapeMarkdownTableText(cells[1]),
                        url: rowUrl,
                        remark: unescapeMarkdownTableText(cells[3])
                    }
                };
            }
        }

        return {
            tableRange: lastTableRange,
            entry: null
        };
    }

    function getNextBookmarkNo(lines) {
        let maxNo = 0;
        const tableRanges = findBookmarkTables(lines);

        for (const tableRange of tableRanges) {
            for (let i = tableRange.dividerIndex + 1; i <= tableRange.endIndex; i += 1) {
                const cells = parseMarkdownTableLine(lines[i]);
                if (!cells || cells.length < 4 || !/^\d+$/.test(cells[0])) {
                    continue;
                }

                maxNo = Math.max(maxNo, Number(cells[0]));
            }
        }

        return maxNo + 1;
    }

    function buildBookmarkRow(no, title, url, remark) {
        return `| ${no} | ${escapeMarkdownTableText(title)} | <${url}> | ${escapeMarkdownTableText(remark)} |`;
    }

    function buildInitialDocument(row) {
        return [
            '# Bookmarks',
            '',
            TABLE_HEADER_ROW,
            TABLE_DIVIDER_ROW,
            row
        ].join('\n');
    }

    function buildUpdatedDocument(existingText, row) {
        const normalizedText = String(existingText || '').replace(/\r\n/g, '\n');
        if (!normalizedText.trim()) {
            return `${buildInitialDocument(row)}\n`;
        }

        const lines = normalizedText.split('\n');
        const tableRanges = findBookmarkTables(lines);
        const tableRange = tableRanges.length > 0 ? tableRanges[tableRanges.length - 1] : null;

        if (!tableRange) {
            const spacer = normalizedText.endsWith('\n\n') ? '' : normalizedText.endsWith('\n') ? '\n' : '\n\n';
            return `${normalizedText}${spacer}## Bookmark Table\n\n${TABLE_HEADER_ROW}\n${TABLE_DIVIDER_ROW}\n${row}\n`;
        }

        const updatedLines = [
            ...lines.slice(0, tableRange.endIndex + 1),
            row,
            ...lines.slice(tableRange.endIndex + 1)
        ];

        return updatedLines.join('\n');
    }

    function buildDocumentWithReplacedRow(existingText, lineIndex, row) {
        const lines = String(existingText || '').replace(/\r\n/g, '\n').split('\n');
        lines[lineIndex] = row;
        return lines.join('\n');
    }

    function buildAppendPayload(existingText, row) {
        const normalizedText = String(existingText || '').replace(/\r\n/g, '\n');
        if (!normalizedText.trim()) {
            return `${buildInitialDocument(row)}\n`;
        }

        const lines = normalizedText.split('\n');
        const tableRanges = findBookmarkTables(lines);
        const lastTableRange = tableRanges.length > 0 ? tableRanges[tableRanges.length - 1] : null;
        const trailingNewlines = countTrailingNewlines(normalizedText);
        const trailingLines = lastTableRange ? lines.slice(lastTableRange.endIndex + 1) : [];
        const canAppendRowToLastTable =
            Boolean(lastTableRange) &&
            trailingNewlines <= 1 &&
            trailingLines.every((line) => line === '');

        if (canAppendRowToLastTable) {
            return `${normalizedText.endsWith('\n') ? '' : '\n'}${row}\n`;
        }

        const spacer = normalizedText.endsWith('\n\n') ? '' : normalizedText.endsWith('\n') ? '\n' : '\n\n';
        return `${spacer}## Bookmark Table\n\n${TABLE_HEADER_ROW}\n${TABLE_DIVIDER_ROW}\n${row}\n`;
    }

    function supportsFileSystemAccess() {
        return typeof window.showOpenFilePicker === 'function';
    }

    function openDatabase() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(DB_NAME, 1);

            request.onupgradeneeded = () => {
                if (!request.result.objectStoreNames.contains(STORE_NAME)) {
                    request.result.createObjectStore(STORE_NAME);
                }
            };

            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }

    async function loadStoredHandle() {
        if (!window.indexedDB) {
            return null;
        }

        try {
            const db = await openDatabase();

            return await new Promise((resolve, reject) => {
                const transaction = db.transaction(STORE_NAME, 'readonly');
                const request = transaction.objectStore(STORE_NAME).get(HANDLE_KEY);

                request.onsuccess = () => resolve(request.result || null);
                request.onerror = () => reject(request.error);
                transaction.oncomplete = () => db.close();
                transaction.onerror = () => {
                    db.close();
                    reject(transaction.error);
                };
            });
        } catch (error) {
            console.warn('[Bookmarker] Failed to load stored handle:', error);
            return null;
        }
    }

    async function saveHandle(handle) {
        if (!window.indexedDB) {
            return;
        }

        try {
            const db = await openDatabase();

            await new Promise((resolve, reject) => {
                const transaction = db.transaction(STORE_NAME, 'readwrite');
                transaction.objectStore(STORE_NAME).put(handle, HANDLE_KEY);

                transaction.oncomplete = () => {
                    db.close();
                    resolve();
                };
                transaction.onerror = () => {
                    db.close();
                    reject(transaction.error);
                };
            });
        } catch (error) {
            console.warn('[Bookmarker] Failed to persist file handle:', error);
        }
    }

    async function pickTargetFile() {
        if (!supportsFileSystemAccess()) {
            throw new Error('当前环境不支持直接写入本地文件,请使用最新版 Chrome 或 Edge。');
        }

        const handles = await window.showOpenFilePicker({
            multiple: false,
            types: [
                {
                    description: 'Markdown file',
                    accept: {
                        'text/markdown': ['.md', '.markdown'],
                        'text/plain': ['.md', '.markdown']
                    }
                }
            ]
        });

        const handle = handles?.[0];
        if (!handle) {
            throw new Error('没有选中文件。');
        }

        await saveHandle(handle);
        return handle;
    }

    async function getTargetFileHandle(forcePick) {
        if (!supportsFileSystemAccess()) {
            throw new Error('当前浏览器不支持本地文件写入,请使用最新版 Chrome 或 Edge。');
        }

        let handle = null;

        if (!forcePick) {
            handle = await loadStoredHandle();
        }

        if (!handle) {
            handle = await pickTargetFile();
        }

        return handle;
    }

    async function appendTextToFile(handle, text) {
        const file = await handle.getFile();
        const writable = await handle.createWritable({ keepExistingData: true });
        await writable.seek(file.size);
        await writable.write(text);
        await writable.close();
    }

    async function writeTextToFile(handle, text) {
        const writable = await handle.createWritable();
        await writable.write(text);
        await writable.close();
    }

    async function createBackupSnapshot(handleName, text) {
        if (typeof navigator.storage?.getDirectory !== 'function') {
            throw new Error('当前浏览器不支持本地备份快照,已中止更新。');
        }

        const rootDirectory = await navigator.storage.getDirectory();
        const backupDirectory = await rootDirectory.getDirectoryHandle(BACKUP_DIRECTORY_NAME, { create: true });
        const backupHandle = await backupDirectory.getFileHandle(buildBackupFileName(handleName), { create: true });
        const writable = await backupHandle.createWritable();
        await writable.write(String(text || ''));
        await writable.close();
        return backupHandle.name;
    }

    async function saveBookmarkToFile(handle, options = {}) {
        const file = await handle.getFile();
        const existingText = await file.text();
        const currentUrl = normalizeUrl(location.href);
        const nextRemark = normalizeText(options.remark || '');

        if (!currentUrl) {
            throw new Error('当前页面链接无效,无法保存。');
        }

        const normalizedText = String(existingText || '').replace(/\r\n/g, '\n');
        const lines = normalizedText.split('\n');
        const { tableRange, entry } = findBookmarkEntryByUrl(lines, currentUrl);

        if (entry && !options.allowRemarkUpdate) {
            return {
                action: 'duplicate'
            };
        }

        if (entry && options.allowRemarkUpdate) {
            if (entry.remark === nextRemark) {
                return {
                    action: 'unchanged',
                    nextNo: entry.no
                };
            }

            const backupName = await createBackupSnapshot(handle.name || 'bookmarks.md', existingText);
            const row = buildBookmarkRow(entry.no, getPageTitle(), currentUrl, nextRemark);
            const nextText = buildDocumentWithReplacedRow(existingText, entry.lineIndex, row);

            await writeTextToFile(handle, nextText);

            return {
                action: 'updated',
                nextNo: entry.no,
                previousRemark: entry.remark,
                backupName
            };
        }

        const nextNo = getNextBookmarkNo(lines);
        const row = buildBookmarkRow(nextNo, getPageTitle(), currentUrl, nextRemark);
        const appendPayload = buildAppendPayload(existingText, row);

        await appendTextToFile(handle, appendPayload);

        return {
            action: 'created',
            nextNo
        };
    }

    function setStatus(message, tone) {
        const status = document.getElementById(STATUS_ID);
        if (!status) {
            return;
        }

        status.textContent = message;
        status.dataset.tone = tone || 'info';
    }

    function setFileName(name) {
        const fileNode = document.getElementById(FILE_ID);
        if (!fileNode) {
            return;
        }

        fileNode.textContent = name ? `文件: ${name}` : '文件: 未选择';
    }

    function setBusy(nextBusy) {
        isBusy = nextBusy;

        const saveButton = document.getElementById(SAVE_ID);
        const saveWithRemarkButton = document.getElementById(SAVE_WITH_REMARK_ID);
        const pickButton = document.getElementById(PICK_ID);
        const remarkSaveButton = document.getElementById(REMARK_SAVE_ID);
        const remarkCancelButton = document.getElementById(REMARK_CANCEL_ID);

        if (saveButton) {
            saveButton.disabled = nextBusy;
        }

        if (saveWithRemarkButton) {
            saveWithRemarkButton.disabled = nextBusy;
        }

        if (pickButton) {
            pickButton.disabled = nextBusy;
        }

        if (remarkSaveButton) {
            remarkSaveButton.disabled = nextBusy;
        }

        if (remarkCancelButton) {
            remarkCancelButton.disabled = nextBusy;
        }
    }

    function setPanelOpen(nextOpen) {
        isPanelOpen = Boolean(nextOpen);

        const root = document.getElementById(ROOT_ID);
        const panel = document.getElementById(PANEL_ID);
        const toggleButton = document.getElementById(TOGGLE_ID);

        if (root) {
            root.dataset.open = isPanelOpen ? 'true' : 'false';
        }

        if (panel) {
            panel.hidden = !isPanelOpen;
        }

        if (toggleButton) {
            toggleButton.textContent = 'B';
            toggleButton.setAttribute('aria-expanded', isPanelOpen ? 'true' : 'false');
            toggleButton.title = isPanelOpen ? '收起 Bookmarker' : '展开 Bookmarker';
        }

        if (!isPanelOpen) {
            setRemarkDialogOpen(false);
        }
    }

    function togglePanel() {
        setPanelOpen(!isPanelOpen);
    }

    function setRemarkDialogOpen(nextOpen) {
        isRemarkDialogOpen = Boolean(nextOpen);

        const dialog = document.getElementById(REMARK_DIALOG_ID);
        if (dialog) {
            dialog.hidden = !isRemarkDialogOpen;
        }

        if (!isRemarkDialogOpen) {
            remarkDraftContext = null;
        }
    }

    function openRemarkDialog(options) {
        const input = document.getElementById(REMARK_INPUT_ID);
        const hint = document.getElementById(REMARK_HINT_ID);

        remarkDraftContext = {
            handle: options.handle
        };

        if (hint) {
            hint.textContent = options.hint || '输入备注后保存到 remark。';
        }

        if (input) {
            input.value = options.initialRemark || '';
        }

        setPanelOpen(true);
        setRemarkDialogOpen(true);

        window.setTimeout(() => {
            if (!input) {
                return;
            }

            input.focus();
            input.setSelectionRange(input.value.length, input.value.length);
        }, 0);
    }

    async function prepareRemarkSave() {
        if (isBusy) {
            return;
        }

        setPanelOpen(true);
        setBusy(true);
        setStatus('正在读取备注信息...', 'info');

        try {
            const handle = await getTargetFileHandle(false);
            setFileName(handle.name || '');

            const file = await handle.getFile();
            const existingText = await file.text();
            const currentUrl = normalizeUrl(location.href);
            const lines = String(existingText || '').replace(/\r\n/g, '\n').split('\n');
            const { entry } = findBookmarkEntryByUrl(lines, currentUrl);

            if (entry) {
                openRemarkDialog({
                    handle,
                    initialRemark: entry.remark,
                    hint: entry.remark
                        ? '该链接已有备注,可修改后保存。'
                        : '该链接已存在,当前 remark 为空,可直接补充后保存。'
                });
                setStatus(entry.remark ? '已载入原备注。' : '可为现有链接补充备注。', 'info');
                return;
            }

            openRemarkDialog({
                handle,
                initialRemark: '',
                hint: '当前链接尚未保存,输入备注后会新增一行。'
            });
            setStatus('请输入备注后保存。', 'info');
        } catch (error) {
            if (error?.name === 'AbortError') {
                setStatus('已取消选择文件。', 'info');
            } else if (error?.name === 'NotAllowedError' || error?.name === 'SecurityError') {
                console.error('[Bookmarker] Load remark failed:', error);
                setStatus('目标文件权限不可用,请点击“更换目标文件”后重新选择。', 'error');
            } else {
                console.error('[Bookmarker] Load remark failed:', error);
                setStatus(error?.message || '读取备注信息失败,请重试。', 'error');
            }
        } finally {
            setBusy(false);
        }
    }

    async function submitRemarkSave() {
        if (isBusy || !remarkDraftContext?.handle) {
            return;
        }

        const input = document.getElementById(REMARK_INPUT_ID);
        const remark = normalizeText(input?.value || '');

        setBusy(true);
        setStatus('正在保存备注...', 'info');

        try {
            const handle = remarkDraftContext.handle;
            const result = await saveBookmarkToFile(handle, {
                remark,
                allowRemarkUpdate: true
            });

            setFileName(handle.name || '');
            setRemarkDialogOpen(false);

            if (result.action === 'updated') {
                setStatus(`已更新现有链接的备注,并创建备份 ${result.backupName}。`, 'success');
                return;
            }

            if (result.action === 'unchanged') {
                setStatus('备注未变化,文件未更新。', 'info');
                return;
            }

            setStatus(`已保存到表格,第 ${result.nextNo} 条。`, 'success');
        } catch (error) {
            if (error?.name === 'NotAllowedError' || error?.name === 'SecurityError') {
                console.error('[Bookmarker] Save remark failed:', error);
                setStatus('目标文件权限不可用,请点击“更换目标文件”后重新选择。', 'error');
            } else {
                console.error('[Bookmarker] Save remark failed:', error);
                setStatus(error?.message || '保存备注失败,请重试。', 'error');
            }
        } finally {
            setBusy(false);
        }
    }

    async function saveCurrentPage(options = {}) {
        if (isBusy) {
            return;
        }

        setPanelOpen(true);
        setBusy(true);
        setStatus(options.forcePick ? '请选择已有的 Markdown 文件...' : '正在保存当前页面...', 'info');

        try {
            const handle = await getTargetFileHandle(Boolean(options.forcePick));
            setFileName(handle.name || '');
            setStatus('已获取目标文件,正在写入 Markdown...', 'info');
            const result = await saveBookmarkToFile(handle);

            if (result.action === 'duplicate') {
                setStatus('该链接已存在,未更新文件。', 'error');
                return;
            }

            setStatus(`已保存到表格,第 ${result.nextNo} 条。`, 'success');
        } catch (error) {
            if (error?.name === 'AbortError') {
                setStatus('已取消选择文件。', 'info');
            } else if (error?.name === 'NotAllowedError' || error?.name === 'SecurityError') {
                console.error('[Bookmarker] Save failed:', error);
                setStatus('目标文件权限不可用,请点击“更换目标文件”后重新选择。', 'error');
            } else {
                console.error('[Bookmarker] Save failed:', error);
                setStatus(error?.message || '保存失败,请重试。', 'error');
            }
        } finally {
            setBusy(false);
        }
    }

    function isEditableTarget(target) {
        if (!target) {
            return false;
        }

        const tagName = String(target.tagName || '').toUpperCase();
        return (
            target.isContentEditable ||
            tagName === 'INPUT' ||
            tagName === 'TEXTAREA' ||
            tagName === 'SELECT'
        );
    }

    function handleKeydown(event) {
        if (event.key === 'Escape' && isRemarkDialogOpen) {
            setRemarkDialogOpen(false);
            return;
        }

        if (event.key === 'Escape' && isPanelOpen) {
            setPanelOpen(false);
            return;
        }

        if (isEditableTarget(event.target)) {
            return;
        }

        if (event.altKey && event.shiftKey && event.code === 'KeyB') {
            event.preventDefault();
            saveCurrentPage();
        }
    }

    function createStyle() {
        if (document.getElementById(`${APP_ID}-style`)) {
            return;
        }

        const style = document.createElement('style');
        style.id = `${APP_ID}-style`;
        style.textContent = `
            #${ROOT_ID} {
                position: fixed;
                right: 18px;
                bottom: 18px;
                z-index: 2147483647;
                display: flex;
                flex-direction: column;
                align-items: flex-end;
                gap: 10px;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
            }

            #${ROOT_ID} * {
                box-sizing: border-box;
            }

            #${TOGGLE_ID} {
                border: none;
                width: 46px;
                height: 46px;
                border-radius: 999px;
                padding: 0;
                display: inline-flex;
                align-items: center;
                justify-content: center;
                font-size: 18px;
                font-weight: 700;
                cursor: pointer;
                color: #ffffff;
                background: linear-gradient(135deg, #0f766e, #0d9488);
                box-shadow: 0 12px 30px rgba(0, 0, 0, 0.24);
                transition: transform 0.15s ease, opacity 0.15s ease;
            }

            #${TOGGLE_ID}:hover {
                transform: translateY(-1px);
            }

            #${TOGGLE_ID}:disabled {
                opacity: 0.6;
                cursor: wait;
                transform: none;
            }

            #${PANEL_ID} {
                width: 200px;
                padding: 12px;
                border-radius: 14px;
                background: rgba(18, 18, 18, 0.92);
                color: #f5f5f5;
                box-shadow: 0 12px 30px rgba(0, 0, 0, 0.24);
                border: 1px solid rgba(255, 255, 255, 0.12);
                backdrop-filter: blur(10px);
            }

            #${PANEL_ID}[hidden] {
                display: none !important;
            }

            #${REMARK_DIALOG_ID} {
                width: 240px;
                padding: 12px;
                border-radius: 14px;
                background: rgba(18, 18, 18, 0.96);
                color: #f5f5f5;
                box-shadow: 0 12px 30px rgba(0, 0, 0, 0.24);
                border: 1px solid rgba(255, 255, 255, 0.12);
                backdrop-filter: blur(10px);
            }

            #${REMARK_DIALOG_ID}[hidden] {
                display: none !important;
            }

            #${REMARK_HINT_ID} {
                margin: 6px 0 10px;
                font-size: 12px;
                line-height: 1.5;
                color: #cbd5e1;
            }

            #${REMARK_INPUT_ID} {
                width: 100%;
                min-height: 90px;
                resize: vertical;
                border: 1px solid rgba(255, 255, 255, 0.14);
                border-radius: 10px;
                background: rgba(255, 255, 255, 0.06);
                color: #f8fafc;
                padding: 10px 12px;
                font-size: 13px;
                line-height: 1.5;
                outline: none;
                font-family: inherit;
            }

            #${REMARK_INPUT_ID}:focus {
                border-color: rgba(37, 99, 235, 0.85);
                box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
            }

            #${REMARK_DIALOG_ID} .${APP_ID}-remark-actions {
                display: flex;
                gap: 8px;
                margin-top: 10px;
            }

            #${REMARK_DIALOG_ID} .${APP_ID}-remark-actions button {
                flex: 1 1 0;
                border: none;
                border-radius: 10px;
                padding: 10px 12px;
                font-size: 13px;
                font-weight: 600;
                cursor: pointer;
                transition: transform 0.15s ease, opacity 0.15s ease, background 0.15s ease;
            }

            #${REMARK_DIALOG_ID} .${APP_ID}-remark-actions button:hover {
                transform: translateY(-1px);
            }

            #${REMARK_DIALOG_ID} .${APP_ID}-remark-actions button:disabled {
                opacity: 0.6;
                cursor: wait;
                transform: none;
            }

            #${REMARK_SAVE_ID} {
                background: linear-gradient(135deg, #1d4ed8, #2563eb);
                color: #ffffff;
            }

            #${REMARK_CANCEL_ID} {
                background: rgba(255, 255, 255, 0.1);
                color: #f3f4f6;
            }

            #${TITLE_ID} {
                font-size: 13px;
                font-weight: 700;
                margin-bottom: 4px;
            }

            #${FILE_ID} {
                font-size: 12px;
                line-height: 1.4;
                color: #d4d4d4;
                margin-bottom: 10px;
                word-break: break-word;
            }

            #${PANEL_ID} button {
                width: 100%;
                border: none;
                border-radius: 10px;
                padding: 10px 12px;
                font-size: 13px;
                font-weight: 600;
                cursor: pointer;
                transition: transform 0.15s ease, opacity 0.15s ease, background 0.15s ease;
            }

            #${PANEL_ID} button + button {
                margin-top: 8px;
            }

            #${PANEL_ID} button:hover {
                transform: translateY(-1px);
            }

            #${PANEL_ID} button:disabled {
                opacity: 0.6;
                cursor: wait;
                transform: none;
            }

            #${SAVE_ID} {
                background: linear-gradient(135deg, #0f766e, #0d9488);
                color: #ffffff;
            }

            #${SAVE_WITH_REMARK_ID} {
                background: linear-gradient(135deg, #1d4ed8, #2563eb);
                color: #ffffff;
            }

            #${PICK_ID} {
                background: rgba(255, 255, 255, 0.1);
                color: #f3f4f6;
            }

            #${STATUS_ID} {
                min-height: 18px;
                margin-top: 10px;
                font-size: 12px;
                line-height: 1.5;
                color: #d1d5db;
            }

            #${STATUS_ID}[data-tone="success"] {
                color: #86efac;
            }

            #${STATUS_ID}[data-tone="error"] {
                color: #fca5a5;
            }

            @media (max-width: 768px) {
                #${ROOT_ID} {
                    right: 12px;
                    left: 12px;
                    bottom: 12px;
                    align-items: stretch;
                }

                #${TOGGLE_ID} {
                    width: 100%;
                }

                #${PANEL_ID} {
                    width: 100%;
                }

                #${REMARK_DIALOG_ID} {
                    width: 100%;
                }
            }
        `;

        document.documentElement.appendChild(style);
    }

    function createApp() {
        if (document.getElementById(ROOT_ID)) {
            return;
        }

        const root = document.createElement('div');
        root.id = ROOT_ID;
        root.dataset.open = 'false';

        const toggleButton = document.createElement('button');
        toggleButton.id = TOGGLE_ID;
        toggleButton.type = 'button';
        toggleButton.textContent = 'B';
        toggleButton.title = '展开 Bookmarker';
        toggleButton.setAttribute('aria-expanded', 'false');
        toggleButton.addEventListener('click', () => {
            togglePanel();
        });

        const panel = document.createElement('div');
        panel.id = PANEL_ID;
        panel.hidden = true;

        const remarkDialog = document.createElement('div');
        remarkDialog.id = REMARK_DIALOG_ID;
        remarkDialog.hidden = true;

        const remarkTitle = document.createElement('div');
        remarkTitle.id = TITLE_ID + '-remark';
        remarkTitle.textContent = '编辑备注';
        remarkTitle.style.fontSize = '13px';
        remarkTitle.style.fontWeight = '700';

        const remarkHint = document.createElement('div');
        remarkHint.id = REMARK_HINT_ID;
        remarkHint.textContent = '输入备注后保存到 remark。';

        const remarkInput = document.createElement('textarea');
        remarkInput.id = REMARK_INPUT_ID;
        remarkInput.placeholder = '输入备注内容';

        const remarkActions = document.createElement('div');
        remarkActions.className = `${APP_ID}-remark-actions`;

        const remarkSaveButton = document.createElement('button');
        remarkSaveButton.id = REMARK_SAVE_ID;
        remarkSaveButton.type = 'button';
        remarkSaveButton.textContent = '保存备注';
        remarkSaveButton.addEventListener('click', () => {
            submitRemarkSave();
        });

        const remarkCancelButton = document.createElement('button');
        remarkCancelButton.id = REMARK_CANCEL_ID;
        remarkCancelButton.type = 'button';
        remarkCancelButton.textContent = '取消';
        remarkCancelButton.addEventListener('click', () => {
            setRemarkDialogOpen(false);
            setStatus('已取消编辑备注。', 'info');
        });

        remarkActions.appendChild(remarkSaveButton);
        remarkActions.appendChild(remarkCancelButton);
        remarkDialog.appendChild(remarkTitle);
        remarkDialog.appendChild(remarkHint);
        remarkDialog.appendChild(remarkInput);
        remarkDialog.appendChild(remarkActions);

        const title = document.createElement('div');
        title.id = TITLE_ID;
        title.textContent = 'Bookmarker';

        const file = document.createElement('div');
        file.id = FILE_ID;
        file.textContent = '文件: 未选择';

        const saveButton = document.createElement('button');
        saveButton.id = SAVE_ID;
        saveButton.type = 'button';
        saveButton.textContent = '保存当前页面';
        saveButton.addEventListener('click', () => {
            saveCurrentPage();
        });

        const saveWithRemarkButton = document.createElement('button');
        saveWithRemarkButton.id = SAVE_WITH_REMARK_ID;
        saveWithRemarkButton.type = 'button';
        saveWithRemarkButton.textContent = '保存页面(带备注)';
        saveWithRemarkButton.addEventListener('click', () => {
            prepareRemarkSave();
        });

        const pickButton = document.createElement('button');
        pickButton.id = PICK_ID;
        pickButton.type = 'button';
        pickButton.textContent = '更换目标文件';
        pickButton.addEventListener('click', () => {
            saveCurrentPage({ forcePick: true });
        });

        const status = document.createElement('div');
        status.id = STATUS_ID;
        status.textContent = '手动点击保存,或按 Alt + Shift + B。';

        panel.appendChild(title);
        panel.appendChild(file);
        panel.appendChild(saveButton);
        panel.appendChild(saveWithRemarkButton);
        panel.appendChild(pickButton);
        panel.appendChild(status);

        root.appendChild(remarkDialog);
        root.appendChild(panel);
        root.appendChild(toggleButton);
        document.documentElement.appendChild(root);
    }

    function handlePointerDown(event) {
        if (!isPanelOpen) {
            return;
        }

        const root = document.getElementById(ROOT_ID);
        if (root && !root.contains(event.target)) {
            setPanelOpen(false);
        }
    }

    async function hydrateStoredFileName() {
        const handle = await loadStoredHandle();
        if (handle?.name) {
            setFileName(handle.name);
            setStatus('已记住目标文件,可直接保存。', 'info');
        }
    }

    function bootstrap() {
        createStyle();
        createApp();
        document.addEventListener('keydown', handleKeydown, true);
        document.addEventListener('pointerdown', handlePointerDown, true);
        hydrateStoredFileName();
    }

    bootstrap();
})();