Bookmarker

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
})();