标签 const article = document.querySelector("article"); if (article) return article; // 策略 2:role="main" 或 const main = document.querySelector('[role="main"], main'); if (main) return main; // 策略 3:常见正文容器 class/id const selectors = [ ".post-content", ".article-content", ".entry-content", ".content", ".post-body", ".article-body", "#content", "#article", "#post-content", ".markdown-body", ".prose", ".rich-text", ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el && el.textContent.trim().length > 200) return el; } // 策略 4:启发式 — 找文本密度最高的容器 const candidates = document.querySelectorAll("div, section"); let best = null; let bestScore = 0; for (const el of candidates) { // 跳过导航、侧边栏、页脚等 const tag = el.tagName.toLowerCase(); const id = (el.id || "").toLowerCase(); const cls = (el.className || "").toLowerCase(); const skip = /(nav|sidebar|footer|header|menu|comment|widget|ad|banner)/; if (skip.test(id) || skip.test(cls) || skip.test(tag)) continue; const text = el.textContent || ""; const pCount = el.querySelectorAll("p").length; const score = text.length * 0.3 + pCount * 100; if (score > bestScore) { bestScore = score; best = el; } } if (best && best.textContent.trim().length > 100) return best; // 兜底:克隆 body 并移除脚本注入的 UI 元素,避免导出内容混入剪藏面板 const clone = document.body.cloneNode(true); clone.querySelectorAll('[class*="gclip-"], [class*="ldb-"], [id*="ldb-"]').forEach(el => el.remove()); return clone; }, // 将提取的 DOM 转为 Notion blocks(复用 DOMToNotion) toNotionBlocks: (contentEl, imgMode) => { return DOMToNotion.cookedToBlocks(contentEl.innerHTML, imgMode); }, }; // =========================================== // 工作区数据服务(带并发去重) // =========================================== const WorkspaceService = { _inflightRequests: new Map(), _requestSearchItems: async (apiKey, objectType, maxPages = 0, onProgress = null, phase = "") => { let results = []; let cursor = undefined; let pageCount = 0; do { const response = await NotionAPI.search("", { property: "object", value: objectType }, apiKey, cursor); const batch = response.results || []; results = results.concat(batch); cursor = response.has_more ? response.next_cursor : undefined; pageCount++; if (onProgress) { onProgress({ phase, loaded: results.length, hasMore: !!cursor, pageCount, }); } } while (cursor && (maxPages === 0 || pageCount < maxPages)); return results; }, fetchWorkspace: async (apiKey, options = {}) => { if (!apiKey) { return { databases: [], pages: [] }; } const includePages = options.includePages !== false; const maxPages = Number.isFinite(options.maxPages) ? options.maxPages : (parseInt(Storage.get(CONFIG.STORAGE_KEYS.WORKSPACE_MAX_PAGES, CONFIG.DEFAULTS.workspaceMaxPages), 10) || 0); const requestKey = `${apiKey.slice(-8)}:${maxPages}:${includePages ? "all" : "db"}`; if (WorkspaceService._inflightRequests.has(requestKey)) { return WorkspaceService._inflightRequests.get(requestKey); } const requestPromise = (async () => { const dbResults = await WorkspaceService._requestSearchItems( apiKey, "database", maxPages, options.onProgress, "databases" ); const databases = dbResults.map(db => ({ id: db.id?.replace(/-/g, "") || "", title: db.title?.[0]?.plain_text || "无标题数据库", type: "database", url: db.url || "", })).filter(item => item.id); if (!includePages) { return { databases, pages: [] }; } const pageResults = await WorkspaceService._requestSearchItems( apiKey, "page", maxPages, options.onProgress, "pages" ); const pages = pageResults.map(page => ({ id: page.id?.replace(/-/g, "") || "", title: Utils.getPageTitle(page), type: "page", url: page.url || "", parent: page.parent?.type || "", })).filter(item => item.id); return { databases, pages }; })(); WorkspaceService._inflightRequests.set(requestKey, requestPromise); try { return await requestPromise; } finally { WorkspaceService._inflightRequests.delete(requestKey); } }, fetchWorkspaceStaged: async (apiKey, options = {}) => { if (!apiKey) { return { databases: [], pages: [] }; } const includePages = options.includePages !== false; const maxPages = Number.isFinite(options.maxPages) ? options.maxPages : (parseInt(Storage.get(CONFIG.STORAGE_KEYS.WORKSPACE_MAX_PAGES, CONFIG.DEFAULTS.workspaceMaxPages), 10) || 0); const databasesRaw = await WorkspaceService._requestSearchItems( apiKey, "database", maxPages, options.onProgress, "databases" ); const databases = databasesRaw.map(db => ({ id: db.id?.replace(/-/g, "") || "", title: db.title?.[0]?.plain_text || "无标题数据库", type: "database", url: db.url || "", })).filter(item => item.id); options.onPhaseComplete?.("databases", { databases, pages: [] }); if (!includePages) { return { databases, pages: [] }; } const pagesRaw = await WorkspaceService._requestSearchItems( apiKey, "page", maxPages, options.onProgress, "pages" ); const pages = pagesRaw.map(page => ({ id: page.id?.replace(/-/g, "") || "", title: Utils.getPageTitle(page), type: "page", url: page.url || "", parent: page.parent?.type || "", })).filter(item => item.id); const finalWorkspace = { databases, pages }; options.onPhaseComplete?.("pages", finalWorkspace); return finalWorkspace; }, }; // =========================================== // 通用网页导出器 // =========================================== const GenericExporter = { // 构建通用网页的 Notion 属性 buildProperties: (meta) => { const props = { "标题": { title: [{ text: { content: meta.title || "无标题" } }] }, "链接": { url: meta.url }, "来源": { rich_text: [{ text: { content: meta.siteName || "" } }] }, "作者": { rich_text: [{ text: { content: meta.author || "" } }] }, }; if (meta.publishDate) { props["发布日期"] = { date: { start: meta.publishDate } }; } if (meta.description) { props["摘要"] = { rich_text: [{ text: { content: meta.description.substring(0, 2000) } }] }; } return props; }, // 导出当前页面 exportCurrentPage: async (settings) => { const meta = GenericExtractor.extractMeta(); const contentEl = GenericExtractor.extractContent(); const blocks = GenericExtractor.toNotionBlocks(contentEl, settings.imgMode || "external"); // 添加来源信息头 blocks.unshift({ type: "callout", callout: { icon: { type: "emoji", emoji: "🔗" }, rich_text: [{ type: "text", text: { content: `来源: ${meta.url}` } }], }, }); // 处理图片上传 if (settings.imgMode === "upload") { await Exporter.processImageUploads(blocks, settings.apiKey, null); } let page; if (settings.exportTargetType === CONFIG.EXPORT_TARGET_TYPES.PAGE) { page = await NotionAPI.createChildPage( settings.parentPageId, meta.title, blocks, settings.apiKey ); } else { const properties = GenericExporter.buildProperties(meta); page = await NotionAPI.createDatabasePage( settings.databaseId, properties, blocks, settings.apiKey ); } return { page, meta }; }, // 自动设置通用数据库属性 setupDatabaseProperties: async (databaseId, apiKey) => { const requiredProperties = { "标题": { typeName: "title", schema: { title: {} } }, "链接": { typeName: "url", schema: { url: {} } }, "来源": { typeName: "rich_text", schema: { rich_text: {} } }, "作者": { typeName: "rich_text", schema: { rich_text: {} } }, "发布日期": { typeName: "date", schema: { date: {} } }, "摘要": { typeName: "rich_text", schema: { rich_text: {} } }, }; try { const database = await NotionAPI.request("GET", `/databases/${databaseId}`, null, apiKey); const existingProps = database.properties || {}; const propsToAdd = {}; const propsToUpdate = {}; const typeConflicts = []; for (const [name, { typeName, schema }] of Object.entries(requiredProperties)) { const existingProp = existingProps[name]; if (!existingProp) { if (typeName === "title") { const existingTitle = Object.entries(existingProps).find(([_, prop]) => prop.type === "title"); if (existingTitle && existingTitle[0] !== name) { propsToUpdate[existingTitle[0]] = { name: name }; } } else { propsToAdd[name] = schema; } } else if (existingProp.type !== typeName) { typeConflicts.push(`「${name}」期望 ${typeName},实际 ${existingProp.type}`); } } if (typeConflicts.length > 0) { return { success: false, message: `属性类型冲突: ${typeConflicts.join(";")},请手动修改数据库属性后重试` }; } const allChanges = { ...propsToAdd, ...propsToUpdate }; if (Object.keys(allChanges).length === 0) { return { success: true, message: "属性已正确配置" }; } await NotionAPI.request("PATCH", `/databases/${databaseId}`, { properties: allChanges }, apiKey); return { success: true, message: `已添加 ${Object.keys(propsToAdd).length} 个属性` }; } catch (error) { return { success: false, error: error.message }; } }, }; // =========================================== // Linux.do API 封装 // =========================================== const LinuxDoAPI = { getRequestOpts: () => { const csrf = document.querySelector('meta[name="csrf-token"]')?.content; const headers = { "x-requested-with": "XMLHttpRequest" }; if (csrf) headers["x-csrf-token"] = csrf; return { headers }; }, fetchJson: async (url, retries = 2) => { let lastErr = null; const opts = LinuxDoAPI.getRequestOpts(); for (let i = 0; i <= retries; i++) { try { const res = await fetch(url, opts); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (e) { lastErr = e; if (i < retries) await Utils.sleep(250 * (i + 1)); } } throw lastErr || new Error("fetchJson failed"); }, // 获取收藏列表 fetchBookmarks: async (username, page = 0) => { const url = `${window.location.origin}/u/${username}/bookmarks.json?page=${page}`; const data = await LinuxDoAPI.fetchJson(url); return data; }, // 获取所有收藏 fetchAllBookmarks: async (username, onProgress) => { const allBookmarks = []; let page = 0; let hasMore = true; while (hasMore) { const data = await LinuxDoAPI.fetchBookmarks(username, page); const bookmarks = data.user_bookmark_list?.bookmarks || []; if (bookmarks.length === 0) { hasMore = false; } else { allBookmarks.push(...bookmarks); page++; if (onProgress) onProgress(allBookmarks.length); // 检查是否还有更多 hasMore = data.user_bookmark_list?.more_bookmarks_url != null; const delay = Storage.get(CONFIG.STORAGE_KEYS.REQUEST_DELAY, CONFIG.DEFAULTS.requestDelay); await Utils.sleep(delay); // 避免请求过快 } } return allBookmarks; }, // 获取帖子详情 fetchTopicDetail: async (topicId) => { const url = `${window.location.origin}/t/${topicId}.json`; return await LinuxDoAPI.fetchJson(url); }, // 获取帖子所有楼层 fetchAllPosts: async (topicId, onProgress) => { const opts = LinuxDoAPI.getRequestOpts(); // 获取所有帖子 ID const idData = await LinuxDoAPI.fetchJson( `${window.location.origin}/t/${topicId}/post_ids.json?post_number=0&limit=99999` ); let postIds = idData.post_ids || []; // 获取主题详情 const mainData = await LinuxDoAPI.fetchJson(`${window.location.origin}/t/${topicId}.json`); const mainFirstPost = mainData.post_stream?.posts?.[0]; if (mainFirstPost && !postIds.includes(mainFirstPost.id)) { postIds.unshift(mainFirstPost.id); } const opUsername = mainData?.details?.created_by?.username || mainData?.post_stream?.posts?.[0]?.username || ""; const topic = { topicId: String(topicId), title: mainData?.title || "", category: mainData?.category_id ? `分类ID: ${mainData.category_id}` : "", categoryName: "", tags: mainData?.tags || [], url: `${window.location.origin}/t/${topicId}`, opUsername: opUsername, createdAt: mainData?.created_at || "", postsCount: mainData?.posts_count || 0, likeCount: mainData?.like_count || 0, views: mainData?.views || 0, }; // 尝试获取分类名称 try { const categoryBadge = document.querySelector(`.badge-category[data-category-id="${mainData.category_id}"]`); if (categoryBadge) { topic.categoryName = categoryBadge.textContent.trim(); } } catch (e) {} // 分批获取帖子详情 let allPosts = []; for (let i = 0; i < postIds.length; i += 200) { const chunk = postIds.slice(i, i + 200); const q = chunk.map((id) => `post_ids[]=${encodeURIComponent(id)}`).join("&"); const data = await LinuxDoAPI.fetchJson( `${window.location.origin}/t/${topicId}/posts.json?${q}&include_suggested=false` ); const posts = data.post_stream?.posts || []; allPosts = allPosts.concat(posts); if (onProgress) onProgress(Math.min(i + 200, postIds.length), postIds.length); } allPosts.sort((a, b) => a.post_number - b.post_number); return { topic, posts: allPosts }; }, }; // =========================================== // 导出器 // =========================================== const Exporter = { isExporting: false, // 标记是否正在导出(用于与自动导入互斥) // 筛选帖子 filterPosts: (posts, topic, settings) => { return posts.filter((post) => { const postNum = post.post_number; // 楼层范围 if (postNum < settings.rangeStart || postNum > settings.rangeEnd) { return false; } // 只要第一楼 if (settings.onlyFirst && postNum !== 1) { return false; } // 只要楼主 if (settings.onlyOp && post.username !== topic.opUsername) { return false; } return true; }); }, // 构建 Notion 页面属性 buildProperties: (topic, bookmark) => { return { "标题": { title: [{ text: { content: topic.title || "无标题" } }] }, "链接": { url: topic.url }, "分类": { rich_text: [{ text: { content: topic.categoryName || topic.category || "" } }] }, "标签": { multi_select: (topic.tags || []).map(tag => ({ name: typeof tag === 'string' ? tag : (tag.name || '') })).filter(t => t.name) }, "作者": { rich_text: [{ text: { content: topic.opUsername || "" } }] }, "收藏时间": bookmark?.created_at ? { date: { start: bookmark.created_at.split("T")[0] } } : undefined, "帖子数": { number: topic.postsCount || 0 }, "浏览数": { number: topic.views || 0 }, "点赞数": { number: topic.likeCount || 0 }, }; }, // 构建帖子内容 blocks buildContentBlocks: (posts, topic, settings) => { const blocks = []; // 添加帖子信息头 blocks.push({ type: "callout", callout: { icon: { type: "emoji", emoji: "📌" }, rich_text: [{ type: "text", text: { content: `帖子来源: ${topic.url}` } }], }, }); // 处理每个楼层 for (const post of posts) { const isOp = post.username === topic.opUsername; const dateStr = Utils.formatDate(post.created_at); const emoji = isOp ? "🏠" : "💬"; let title = `#${post.post_number} ${post.name || post.username || "匿名"}`; if (isOp) title += " 楼主"; if (dateStr) title += ` · ${dateStr}`; // 转换帖子内容 const contentBlocks = DOMToNotion.cookedToBlocks(post.cooked, settings.imgMode); // 创建 callout 包裹 const children = []; // 添加回复信息 if (post.reply_to_post_number) { children.push({ type: "paragraph", paragraph: { rich_text: [{ type: "text", text: { content: `↩️ 回复 #${post.reply_to_post_number}楼` } }], }, }); } children.push(...contentBlocks); // 跳过空楼层 if (children.length === 0) { children.push({ type: "paragraph", paragraph: { rich_text: [{ type: "text", text: { content: "(内容为空或无法解析)" } }], }, }); } // 拆分超过 100 个子 block 的内容 const maxChildren = 100; for (let i = 0; i < children.length; i += maxChildren) { const chunk = children.slice(i, i + maxChildren); const isFirst = i === 0; const partNum = Math.floor(i / maxChildren) + 1; const totalParts = Math.ceil(children.length / maxChildren); blocks.push({ type: "callout", callout: { icon: { type: "emoji", emoji: isFirst ? emoji : "📎" }, rich_text: [{ type: "text", text: { content: isFirst ? title : `#${post.post_number}楼 续(${partNum}/${totalParts})` } }], children: chunk, }, }); } } return blocks; }, // 处理图片上传 // 注意: Notion File Upload API 返回的 file_id 需要在创建页面时使用特定格式 // 由于 API 限制,目前采用外链模式作为后备方案 processImageUploads: async (blocks, apiKey, onProgress) => { const imageBlocks = blocks.filter(b => b._needsUpload && b.type === "image"); let processed = 0; for (const block of imageBlocks) { try { const uploadResult = await NotionAPI.uploadImageToNotion(block._originalUrl, apiKey, true); if (uploadResult?.fileId) { // Notion File Upload API 需要使用 file_upload 类型引用上传的文件 // 参考: https://developers.notion.com/docs/working-with-files-and-media const blockKey = uploadResult.blockType === "file" ? "file" : "image"; block[blockKey] = { type: "file_upload", file_upload: { id: uploadResult.fileId, }, }; if (blockKey !== "image") delete block.image; if (blockKey !== "file") delete block.file; block.type = blockKey; block._uploaded = true; } else { // 上传失败,回退到外链模式 block.image = { type: "external", external: { url: block._originalUrl }, }; delete block.file; block.type = "image"; } } catch (e) { console.warn("图片上传失败,保留外链:", block._originalUrl, e.message); // 保留外链模式 block.image = { type: "external", external: { url: block._originalUrl }, }; delete block.file; block.type = "image"; } processed++; if (onProgress) onProgress(processed, imageBlocks.length); await Utils.sleep(500); // 避免请求过快 } // 清理临时属性 for (const block of blocks) { delete block._needsUpload; delete block._originalUrl; delete block._uploaded; } // 递归处理子 blocks for (const block of blocks) { if (block.callout?.children) { await Exporter.processImageUploads(block.callout.children, apiKey, null); } } }, // 导出单个帖子 exportTopic: async (bookmark, settings, onProgress) => { const topicId = bookmark.topic_id || bookmark.bookmarkable_id; onProgress?.({ stage: "fetch", message: "获取帖子数据..." }); // 获取帖子详情 const { topic, posts } = await LinuxDoAPI.fetchAllPosts(topicId, (current, total) => { onProgress?.({ stage: "fetch", message: `获取楼层 ${current}/${total}` }); }); // 筛选帖子 const filteredPosts = Exporter.filterPosts(posts, topic, settings); onProgress?.({ stage: "convert", message: "转换内容格式..." }); // 构建内容 const blocks = Exporter.buildContentBlocks(filteredPosts, topic, settings); // 处理图片上传 if (settings.imgMode === "upload") { onProgress?.({ stage: "upload", message: "上传图片..." }); await Exporter.processImageUploads(blocks, settings.apiKey, (current, total) => { onProgress?.({ stage: "upload", message: `上传图片 ${current}/${total}` }); }); } onProgress?.({ stage: "create", message: "创建 Notion 页面..." }); let page; // 根据导出目标类型创建页面 if (settings.exportTargetType === CONFIG.EXPORT_TARGET_TYPES.PAGE) { // 创建为子页面 page = await NotionAPI.createChildPage( settings.parentPageId, topic.title, blocks, settings.apiKey ); } else { // 创建为数据库条目(默认行为) const properties = Exporter.buildProperties(topic, bookmark); page = await NotionAPI.createDatabasePage( settings.databaseId, properties, blocks, settings.apiKey ); } // 标记为已导出 Storage.markTopicExported(topicId); return page; }, // 批量导出 (支持暂停/继续) isPaused: false, isCancelled: false, currentIndex: 0, pause: () => { Exporter.isPaused = true; }, resume: () => { Exporter.isPaused = false; }, cancel: () => { Exporter.isCancelled = true; Exporter.isPaused = false; }, reset: () => { Exporter.isPaused = false; Exporter.isCancelled = false; Exporter.currentIndex = 0; }, exportBookmarks: async (bookmarks, settings, onProgress, startIndex = 0) => { const results = { success: [], failed: [], skipped: [] }; Exporter.reset(); Exporter.isExporting = true; Exporter.currentIndex = startIndex; const concurrency = settings.concurrency || 1; const delay = Storage.get(CONFIG.STORAGE_KEYS.REQUEST_DELAY, CONFIG.DEFAULTS.requestDelay); // 共享队列索引 let nextIndex = startIndex; let completedCount = 0; const worker = async () => { while (true) { // 检查暂停 while (Exporter.isPaused) { await Utils.sleep(200); if (Exporter.isCancelled) return; } if (Exporter.isCancelled) return; // 取任务 const i = nextIndex; if (i >= bookmarks.length) return; nextIndex++; const bookmark = bookmarks[i]; const topicId = bookmark.topic_id || bookmark.bookmarkable_id; const title = bookmark.title || bookmark.name || `帖子 ${topicId}`; const taskNum = i - startIndex + 1; onProgress?.({ current: taskNum, total: bookmarks.length, title: title, stage: "start", isPaused: Exporter.isPaused, }); try { await Exporter.exportTopic(bookmark, settings, (detail) => { onProgress?.({ current: taskNum, total: bookmarks.length, title: title, isPaused: Exporter.isPaused, ...detail, }); }); results.success.push({ topicId, title, url: `https://linux.do/t/${topicId}` }); } catch (error) { console.error(`导出失败: ${title}`, error); results.failed.push({ topicId, title, error: error.message }); } completedCount++; Exporter.currentIndex = completedCount + startIndex; // 请求间隔 if (delay > 0 && nextIndex < bookmarks.length && !Exporter.isCancelled) { await Utils.sleep(delay); } } }; // 启动 N 个 worker const workerCount = Math.min(concurrency, bookmarks.length - startIndex); const workers = []; for (let w = 0; w < workerCount; w++) { workers.push(worker()); // 错开启动避免同时请求 if (w < workerCount - 1) await Utils.sleep(100); } await Promise.all(workers); // 取消时收集剩余为 skipped if (Exporter.isCancelled && nextIndex < bookmarks.length) { for (let i = nextIndex; i < bookmarks.length; i++) { const b = bookmarks[i]; results.skipped.push({ topicId: b.topic_id || b.bookmarkable_id, title: b.title || b.name || `帖子 ${b.topic_id || b.bookmarkable_id}`, }); } } Exporter.isExporting = false; return results; }, }; // =========================================== // 自动导入模块 // =========================================== const AutoImporter = { isRunning: false, timerId: null, deferredWhileHidden: false, visibilityListenerBound: false, // 从 Storage 读取导出设置(不依赖 UI DOM) buildSettings: () => { const exportTargetType = Storage.get(CONFIG.STORAGE_KEYS.EXPORT_TARGET_TYPE, "database"); return { apiKey: Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, ""), databaseId: Storage.get(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, ""), parentPageId: Storage.get(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, ""), exportTargetType, onlyFirst: Storage.get(CONFIG.STORAGE_KEYS.FILTER_ONLY_FIRST, false), onlyOp: Storage.get(CONFIG.STORAGE_KEYS.FILTER_ONLY_OP, false), rangeStart: Storage.get(CONFIG.STORAGE_KEYS.FILTER_RANGE_START, 1), rangeEnd: Storage.get(CONFIG.STORAGE_KEYS.FILTER_RANGE_END, 999999), imgMode: Storage.get(CONFIG.STORAGE_KEYS.IMG_MODE, "external"), concurrency: Storage.get(CONFIG.STORAGE_KEYS.EXPORT_CONCURRENCY, CONFIG.DEFAULTS.exportConcurrency), }; }, // 检查配置是否足够 canStart: () => { if (!Storage.get(CONFIG.STORAGE_KEYS.AUTO_IMPORT_ENABLED, false)) return false; const apiKey = Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, ""); if (!apiKey) return false; const exportTargetType = Storage.get(CONFIG.STORAGE_KEYS.EXPORT_TARGET_TYPE, "database"); if (exportTargetType === "database") { return !!Storage.get(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, ""); } else { return !!Storage.get(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, ""); } }, // 更新状态栏 updateStatus: (text) => { const el = (UI.refs && UI.refs.autoImportStatus) || document.querySelector("#ldb-auto-import-status"); if (el) el.textContent = text; }, // 执行一次自动导入 run: async () => { if (document.hidden) { AutoImporter.deferredWhileHidden = true; return; } if (AutoImporter.isRunning) return; if (Exporter.isExporting) return; // 手动导出进行中,跳过 // 检查配置是否足够(不依赖 AUTO_IMPORT_ENABLED,由调用方判断) const apiKey = Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, ""); if (!apiKey) { AutoImporter.updateStatus("⚠️ 请先配置 Notion API Key"); return; } const exportTargetType = Storage.get(CONFIG.STORAGE_KEYS.EXPORT_TARGET_TYPE, "database"); if (exportTargetType === "database" && !Storage.get(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, "")) { AutoImporter.updateStatus("⚠️ 请先配置 Notion 数据库 ID"); return; } if (exportTargetType === "page" && !Storage.get(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, "")) { AutoImporter.updateStatus("⚠️ 请先配置父页面 ID"); return; } AutoImporter.isRunning = true; const exportBtn = document.querySelector("#ldb-export"); try { const username = Utils.getCurrentLinuxDoUsername(); if (!username) return; AutoImporter.updateStatus("🔄 正在检查新收藏..."); const bookmarks = await LinuxDoAPI.fetchAllBookmarks(username); // 自动导入始终按“新收藏”语义执行,避免轮询时重复全量导入 const newBookmarks = bookmarks.filter(b => { const topicId = String(b.topic_id || b.bookmarkable_id); return !Storage.isTopicExported(topicId); }); if (newBookmarks.length === 0) { AutoImporter.updateStatus(`✅ 没有新收藏 (${new Date().toLocaleTimeString()})`); return; } AutoImporter.updateStatus(`📥 发现 ${newBookmarks.length} 个新收藏,正在导入...`); if (exportBtn) exportBtn.disabled = true; const settings = AutoImporter.buildSettings(); const delay = Storage.get(CONFIG.STORAGE_KEYS.REQUEST_DELAY, CONFIG.DEFAULTS.requestDelay); const concurrency = settings.concurrency || 1; let success = 0, failed = 0; // 共享队列索引 let nextIndex = 0; const worker = async () => { while (true) { const i = nextIndex; if (i >= newBookmarks.length) return; nextIndex++; const bookmark = newBookmarks[i]; const topicId = String(bookmark.topic_id || bookmark.bookmarkable_id); const title = bookmark.title || bookmark.name || `帖子 ${topicId}`; AutoImporter.updateStatus(`📥 导入中 (${i + 1}/${newBookmarks.length}): ${title}`); try { await Exporter.exportTopic(bookmark, settings); success++; } catch (e) { console.error(`自动导入失败: ${title}`, e); failed++; } if (delay > 0 && nextIndex < newBookmarks.length) await Utils.sleep(delay); } }; const workerCount = Math.min(concurrency, newBookmarks.length); const workers = []; for (let w = 0; w < workerCount; w++) { workers.push(worker()); if (w < workerCount - 1) await Utils.sleep(100); } await Promise.all(workers); if (typeof UI !== "undefined" && UI.renderBookmarkList) { try { UI.renderBookmarkList(); } catch {} } const statusText = `✅ 自动导入完成: ${success} 个成功${failed > 0 ? `,${failed} 个失败` : ""} (${new Date().toLocaleTimeString()})`; AutoImporter.updateStatus(statusText); if (success > 0 && typeof GM_notification === "function") { GM_notification({ title: "自动导入完成", text: `成功导入 ${success} 个新收藏到 Notion`, timeout: 5000, }); } } catch (e) { console.error("自动导入出错:", e); AutoImporter.updateStatus(`❌ 自动导入出错: ${e.message}`); } finally { AutoImporter.isRunning = false; if (exportBtn) exportBtn.disabled = false; } }, startPolling: (intervalMinutes) => { AutoImporter.stopPolling(); if (intervalMinutes > 0) { AutoImporter.timerId = setInterval(() => AutoImporter.run(), intervalMinutes * 60 * 1000); } }, ensureVisibilityListener: () => { if (AutoImporter.visibilityListenerBound) return; document.addEventListener("visibilitychange", () => { if (!document.hidden && AutoImporter.deferredWhileHidden) { AutoImporter.deferredWhileHidden = false; AutoImporter.run(); } }); AutoImporter.visibilityListenerBound = true; }, stopPolling: () => { if (AutoImporter.timerId) { clearInterval(AutoImporter.timerId); AutoImporter.timerId = null; } }, init: () => { if (!AutoImporter.canStart()) return; AutoImporter.ensureVisibilityListener(); setTimeout(() => { AutoImporter.run(); const interval = Storage.get(CONFIG.STORAGE_KEYS.AUTO_IMPORT_INTERVAL, CONFIG.DEFAULTS.autoImportInterval); if (interval > 0) AutoImporter.startPolling(interval); }, 3000); }, }; const UpdateChecker = { timerId: null, isChecking: false, getCurrentVersion: () => { if (typeof GM_info !== "undefined" && GM_info?.script?.version) { return GM_info.script.version; } return "3.4.3"; }, compareVersions: (a, b) => { const parse = (v) => String(v || "0") .replace(/^v/i, "") .split(".") .map((n) => parseInt(n, 10) || 0); const va = parse(a); const vb = parse(b); const len = Math.max(va.length, vb.length); for (let i = 0; i < len; i++) { const na = va[i] || 0; const nb = vb[i] || 0; if (na > nb) return 1; if (na < nb) return -1; } return 0; }, fetchLatestVersion: () => { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: "https://api.github.com/repos/Smith-106/LD-Notion/releases/latest", headers: { "Accept": "application/vnd.github+json", "User-Agent": "LD-Notion-UserScript", }, timeout: 15000, onload: (response) => { if (response.status !== 200) { reject(new Error(`更新检查失败: HTTP ${response.status}`)); return; } try { const data = JSON.parse(response.responseText || "{}"); const version = String(data.tag_name || data.name || "").replace(/^v/i, "").trim(); if (!version) { reject(new Error("未获取到版本号")); return; } resolve(version); } catch { reject(new Error("解析更新信息失败")); } }, ontimeout: () => reject(new Error("更新检查超时")), onerror: () => reject(new Error("网络错误,无法检查更新")), }); }); }, saveResult: (result) => { const checkedAt = Date.now(); Storage.set(CONFIG.STORAGE_KEYS.UPDATE_LAST_CHECK_AT, checkedAt); Storage.set(CONFIG.STORAGE_KEYS.UPDATE_LAST_RESULT, JSON.stringify({ ...result, checkedAt })); if (result.latestVersion) { Storage.set(CONFIG.STORAGE_KEYS.UPDATE_LAST_SEEN_VERSION, result.latestVersion); } }, updateStatusText: (text) => { const el = (UI.refs && UI.refs.updateCheckStatus) || document.querySelector("#ldb-update-check-status"); if (el) el.textContent = text; }, renderLastStatus: () => { const raw = Storage.get(CONFIG.STORAGE_KEYS.UPDATE_LAST_RESULT, ""); if (!raw) { UpdateChecker.updateStatusText("尚未检查更新"); return; } try { const result = JSON.parse(raw); const checkedAtText = result.checkedAt ? new Date(result.checkedAt).toLocaleString("zh-CN") : "未知时间"; const latestText = result.latestVersion ? `,最新 v${result.latestVersion}` : ""; if (result.status === "update-available") { UpdateChecker.updateStatusText(`发现新版本(上次检查:${checkedAtText}${latestText})`); } else if (result.status === "up-to-date") { UpdateChecker.updateStatusText(`已是最新(上次检查:${checkedAtText}${latestText})`); } else if (result.status === "error") { UpdateChecker.updateStatusText(`上次检查失败:${result.message || "未知错误"}`); } else { UpdateChecker.updateStatusText(`上次检查:${checkedAtText}`); } } catch { UpdateChecker.updateStatusText("更新状态读取失败"); } }, check: async ({ manual = false } = {}) => { if (UpdateChecker.isChecking) return; UpdateChecker.isChecking = true; if (manual) { UI.showStatus("正在检查更新...", "info"); } try { const currentVersion = UpdateChecker.getCurrentVersion(); const latestVersion = await UpdateChecker.fetchLatestVersion(); const cmp = UpdateChecker.compareVersions(latestVersion, currentVersion); if (cmp > 0) { const message = `发现新版本 v${latestVersion}(当前 v${currentVersion})。脚本可直接更新;ZIP/解压扩展需手动重新安装或在扩展页重新加载。`; UpdateChecker.saveResult({ status: "update-available", latestVersion, currentVersion, message, }); UpdateChecker.renderLastStatus(); if (manual) UI.showStatus(message, "info"); } else { const message = `当前已是最新版本 v${currentVersion}`; UpdateChecker.saveResult({ status: "up-to-date", latestVersion, currentVersion, message, }); UpdateChecker.renderLastStatus(); if (manual) UI.showStatus(message, "success"); } } catch (error) { const message = error?.message || "更新检查失败"; UpdateChecker.saveResult({ status: "error", message }); UpdateChecker.renderLastStatus(); if (manual) UI.showStatus(message, "error"); } finally { UpdateChecker.isChecking = false; } }, startPolling: (hours) => { UpdateChecker.stopPolling(); const intervalHours = parseInt(hours, 10) || 0; if (intervalHours > 0) { UpdateChecker.timerId = setInterval(() => { UpdateChecker.check({ manual: false }); }, intervalHours * 60 * 60 * 1000); } }, stopPolling: () => { if (UpdateChecker.timerId) { clearInterval(UpdateChecker.timerId); UpdateChecker.timerId = null; } }, init: () => { const enabled = Storage.get(CONFIG.STORAGE_KEYS.UPDATE_AUTO_CHECK_ENABLED, CONFIG.DEFAULTS.updateAutoCheckEnabled); const intervalHours = Storage.get(CONFIG.STORAGE_KEYS.UPDATE_CHECK_INTERVAL_HOURS, CONFIG.DEFAULTS.updateCheckIntervalHours); UpdateChecker.stopPolling(); UpdateChecker.renderLastStatus(); if (enabled) { UpdateChecker.check({ manual: false }); UpdateChecker.startPolling(intervalHours); } }, }; const GitHubAutoImporter = { isRunning: false, timerId: null, deferredWhileHidden: false, visibilityListenerBound: false, canStart: () => { if (!Storage.get(CONFIG.STORAGE_KEYS.GITHUB_AUTO_IMPORT_ENABLED, false)) return false; const username = Storage.get(CONFIG.STORAGE_KEYS.GITHUB_USERNAME, ""); const token = Storage.get(CONFIG.STORAGE_KEYS.GITHUB_TOKEN, ""); if (!username && !token) return false; const apiKey = Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, ""); const databaseId = Storage.get(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, ""); return !!(apiKey && databaseId); }, updateStatus: (text) => { const el = (UI.refs && UI.refs.autoImportStatus) || document.querySelector("#ldb-auto-import-status"); if (el) el.textContent = text; }, buildSettings: () => { return { apiKey: Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, ""), databaseId: Storage.get(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, ""), username: Storage.get(CONFIG.STORAGE_KEYS.GITHUB_USERNAME, ""), token: Storage.get(CONFIG.STORAGE_KEYS.GITHUB_TOKEN, ""), }; }, ensureVisibilityListener: () => { if (GitHubAutoImporter.visibilityListenerBound) return; document.addEventListener("visibilitychange", () => { if (!document.hidden && GitHubAutoImporter.deferredWhileHidden) { GitHubAutoImporter.deferredWhileHidden = false; GitHubAutoImporter.run(); } }); GitHubAutoImporter.visibilityListenerBound = true; }, run: async () => { if (document.hidden) { GitHubAutoImporter.deferredWhileHidden = true; return; } if (GitHubAutoImporter.isRunning) return; const settings = GitHubAutoImporter.buildSettings(); if (!settings.apiKey || !settings.databaseId) { GitHubAutoImporter.updateStatus("⚠️ 请先配置 Notion API Key 和数据库 ID"); return; } if (!settings.username && !settings.token) { GitHubAutoImporter.updateStatus("⚠️ 请先配置 GitHub 用户名或 Token"); return; } GitHubAutoImporter.isRunning = true; try { GitHubAutoImporter.updateStatus("🔄 正在检查 GitHub 新收藏..."); const types = GitHubAPI.getImportTypes(); const candidates = []; for (const type of types) { if (type === "stars") { const repos = await GitHubAPI.fetchStarredRepos(settings.username, settings.token); candidates.push(...UI.mapGitHubItemsToBookmarks(repos, "stars")); } else if (type === "repos") { const repos = await GitHubAPI.fetchUserRepos(settings.username, settings.token); const ownRepos = repos.filter(r => !r.fork); candidates.push(...UI.mapGitHubItemsToBookmarks(ownRepos, "repos")); } else if (type === "forks") { const forks = await GitHubAPI.fetchForkedRepos(settings.username, settings.token); candidates.push(...UI.mapGitHubItemsToBookmarks(forks, "forks")); } else if (type === "gists") { const gists = await GitHubAPI.fetchUserGists(settings.username, settings.token); candidates.push(...UI.mapGitHubItemsToBookmarks(gists, "gists")); } } const newItems = candidates.filter(item => !UI.isBookmarkExported(item)); if (newItems.length === 0) { GitHubAutoImporter.updateStatus(`✅ 没有新的 GitHub 收藏 (${new Date().toLocaleTimeString()})`); return; } const result = await UI.exportGitHubSelected(newItems, { apiKey: settings.apiKey, databaseId: settings.databaseId, }, (current, total, title) => { GitHubAutoImporter.updateStatus(`📥 GitHub 自动导入中 (${current}/${total}): ${title}`); }); GitHubAutoImporter.updateStatus(`✅ GitHub 自动导入完成: 成功 ${result.success.length} 个${result.failed.length > 0 ? `,失败 ${result.failed.length} 个` : ""} (${new Date().toLocaleTimeString()})`); } catch (error) { console.error("GitHub 自动导入出错:", error); GitHubAutoImporter.updateStatus(`❌ GitHub 自动导入出错: ${error.message}`); } finally { GitHubAutoImporter.isRunning = false; } }, startPolling: (intervalMinutes) => { GitHubAutoImporter.stopPolling(); if (intervalMinutes > 0) { GitHubAutoImporter.timerId = setInterval(() => GitHubAutoImporter.run(), intervalMinutes * 60 * 1000); } }, stopPolling: () => { if (GitHubAutoImporter.timerId) { clearInterval(GitHubAutoImporter.timerId); GitHubAutoImporter.timerId = null; } }, init: () => { if (!GitHubAutoImporter.canStart()) return; GitHubAutoImporter.ensureVisibilityListener(); setTimeout(() => { GitHubAutoImporter.run(); const interval = Storage.get(CONFIG.STORAGE_KEYS.GITHUB_AUTO_IMPORT_INTERVAL, CONFIG.DEFAULTS.githubAutoImportInterval); if (interval > 0) GitHubAutoImporter.startPolling(interval); }, 3000); }, }; // =========================================== // GitHub API 模块 // =========================================== const GitHubAPI = { _readmeCache: {}, _fetchPaginated: (url, token = "", label = "GitHub") => { return new Promise((resolve, reject) => { const allItems = []; let page = 1; const perPage = 100; const fetchPage = () => { const separator = url.includes("?") ? "&" : "?"; const pagedUrl = `${url}${separator}per_page=${perPage}&page=${page}`; const headers = { "Accept": "application/vnd.github.v3+json", "User-Agent": "LD-Notion-UserScript", }; if (token) headers["Authorization"] = `Bearer ${token}`; GM_xmlhttpRequest({ method: "GET", url: pagedUrl, headers, onload: (response) => { if (response.status === 200) { try { const items = JSON.parse(response.responseText); if (items.length === 0) return resolve(allItems); allItems.push(...items); if (items.length < perPage) return resolve(allItems); page++; setTimeout(fetchPage, 300); } catch (e) { reject(new Error(`解析 ${label} 响应失败`)); } } else if (response.status === 403) { reject(new Error(`${label} API 速率限制,请稍后再试或配置 Token`)); } else if (response.status === 404) { reject(new Error(`${label} 资源不存在`)); } else { reject(new Error(`${label} API 错误: ${response.status}`)); } }, onerror: () => reject(new Error(`网络错误,无法连接 ${label}`)), }); }; fetchPage(); }); }, // 获取用户 starred repos(带分页) fetchStarredRepos: (username, token = "") => { const url = token ? `https://api.github.com/user/starred` : `https://api.github.com/users/${encodeURIComponent(username)}/starred`; return GitHubAPI._fetchPaginated(url, token, "GitHub Stars"); }, // 获取用户自己的仓库 fetchUserRepos: (username, token = "") => { const url = token ? `https://api.github.com/user/repos?type=owner&sort=updated` : `https://api.github.com/users/${encodeURIComponent(username)}/repos?sort=updated`; return GitHubAPI._fetchPaginated(url, token, "GitHub Repos"); }, // 获取用户 fork 的仓库 fetchForkedRepos: async (username, token = "") => { const allRepos = await GitHubAPI.fetchUserRepos(username, token); return allRepos.filter(r => r.fork); }, // 获取用户的 Gists fetchUserGists: (username, token = "") => { const url = token ? `https://api.github.com/gists` : `https://api.github.com/users/${encodeURIComponent(username)}/gists`; return GitHubAPI._fetchPaginated(url, token, "GitHub Gists"); }, // 获取已导出的 repo 集合 getExported: () => { try { return JSON.parse(Storage.get(CONFIG.STORAGE_KEYS.GITHUB_EXPORTED_REPOS, "{}")); } catch { return {}; } }, // 获取已导出的 gist 集合 getExportedGists: () => { try { return JSON.parse(Storage.get(CONFIG.STORAGE_KEYS.GITHUB_EXPORTED_GISTS, "{}")); } catch { return {}; } }, markExported: (repoFullName) => { const exported = GitHubAPI.getExported(); exported[repoFullName] = Date.now(); Storage.set(CONFIG.STORAGE_KEYS.GITHUB_EXPORTED_REPOS, JSON.stringify(exported)); }, markGistExported: (gistId) => { const exported = GitHubAPI.getExportedGists(); exported[gistId] = Date.now(); Storage.set(CONFIG.STORAGE_KEYS.GITHUB_EXPORTED_GISTS, JSON.stringify(exported)); }, isExported: (repoFullName) => { return !!GitHubAPI.getExported()[repoFullName]; }, isGistExported: (gistId) => { return !!GitHubAPI.getExportedGists()[gistId]; }, // 获取启用的导入类型 getImportTypes: () => { try { return JSON.parse(Storage.get(CONFIG.STORAGE_KEYS.GITHUB_IMPORT_TYPES, CONFIG.DEFAULTS.githubImportTypes)); } catch { return ["stars"]; } }, setImportTypes: (types) => { Storage.set(CONFIG.STORAGE_KEYS.GITHUB_IMPORT_TYPES, JSON.stringify(types)); }, fetchRepoReadme: (repoFullName, token = "") => { if (!repoFullName) return Promise.resolve(""); const cacheKey = `${repoFullName}::${token ? "auth" : "anon"}`; if (Object.prototype.hasOwnProperty.call(GitHubAPI._readmeCache, cacheKey)) { return Promise.resolve(GitHubAPI._readmeCache[cacheKey]); } return new Promise((resolve) => { const headers = { "Accept": "application/vnd.github.v3+json", "User-Agent": "LD-Notion-UserScript", }; if (token) headers["Authorization"] = `Bearer ${token}`; GM_xmlhttpRequest({ method: "GET", url: `https://api.github.com/repos/${repoFullName}/readme`, headers, onload: (response) => { if (response.status === 200) { try { const data = JSON.parse(response.responseText || "{}"); const decoded = Utils.base64DecodeUnicode(data.content || ""); const text = String(decoded || "").replace(/\r\n/g, "\n"); GitHubAPI._readmeCache[cacheKey] = text; resolve(text); return; } catch { GitHubAPI._readmeCache[cacheKey] = ""; resolve(""); return; } } GitHubAPI._readmeCache[cacheKey] = ""; resolve(""); }, onerror: () => { GitHubAPI._readmeCache[cacheKey] = ""; resolve(""); }, }); }); }, }; // =========================================== // GitHub 导出到 Notion 模块 // =========================================== const GitHubExporter = { normalizeText: (text, maxLen = 280) => { if (!text) return ""; const normalized = String(text).replace(/\s+/g, " ").trim(); return normalized.substring(0, maxLen); }, composeTitleWithPrefix: (prefix, candidate, maxLen = 180) => { const safePrefix = GitHubExporter.normalizeText(prefix, maxLen); const safeCandidate = GitHubExporter.normalizeText(candidate, maxLen); if (!safePrefix) return safeCandidate || "无标题"; if (!safeCandidate || safeCandidate === safePrefix) return safePrefix; if (safeCandidate.startsWith(`${safePrefix} - `) || safeCandidate.startsWith(`${safePrefix} · `)) { return safeCandidate.substring(0, maxLen); } return `${safePrefix} · ${safeCandidate}`.substring(0, maxLen); }, extractReadmeInsight: (readmeText = "") => { const text = String(readmeText || "").replace(/\r\n/g, "\n"); if (!text) return { title: "", summary: "" }; const headingMatch = text.match(/^#{1,3}\s+(.+)$/m); const title = GitHubExporter.normalizeText(headingMatch?.[1] || "", 120); const lines = text .split("\n") .map(line => line.trim()) .filter(line => line && !line.startsWith("#") && !line.startsWith("```")); const summary = GitHubExporter.normalizeText(lines.slice(0, 8).join(" "), 320); return { title, summary }; }, inferRepoCategoryHeuristic: (repo, insight, categories = []) => { const available = (categories || []).map(c => String(c || "").trim()).filter(Boolean); if (available.length === 0) return ""; const text = `${repo.full_name || ""} ${repo.name || ""} ${repo.description || ""} ${(repo.topics || []).join(" ")} ${repo.language || ""} ${insight.title || ""} ${insight.summary || ""}`.toLowerCase(); for (const cat of available) { if (text.includes(cat.toLowerCase())) return cat; } const rules = [ { keys: ["llm", "openai", "anthropic", "prompt", "rag", "ai", "agent"], hints: ["ai", "人工智能"] }, { keys: ["react", "vue", "next", "svelte", "frontend", "ui", "css", "tailwind"], hints: ["前端", "ui"] }, { keys: ["node", "express", "fastapi", "backend", "server", "api", "spring"], hints: ["后端", "服务端", "api"] }, { keys: ["devops", "docker", "kubernetes", "k8s", "terraform", "ci", "cd"], hints: ["运维", "devops"] }, { keys: ["docs", "guide", "tutorial", "awesome", "resource", "学习", "教程"], hints: ["文档", "资源", "学习"] }, ]; for (const rule of rules) { if (!rule.keys.some(k => text.includes(k))) continue; const matched = available.find(cat => rule.hints.some(h => cat.toLowerCase().includes(h.toLowerCase()))); if (matched) return matched; } const fallback = available.find(cat => cat.includes("其他")); return fallback || available[available.length - 1]; }, inferRepoTags: (repo, insight) => { const tags = []; const pushTag = (value) => { const clean = GitHubExporter.normalizeText(value, 80); if (!clean) return; if (tags.includes(clean)) return; tags.push(clean); }; (repo.topics || []).forEach(pushTag); pushTag(repo.language || ""); const owner = String(repo.full_name || "").split("/")[0] || ""; pushTag(owner); const lowerText = `${insight.title || ""} ${insight.summary || ""}`.toLowerCase(); const keywordTags = ["ai", "llm", "rag", "agent", "react", "vue", "nextjs", "nodejs", "python", "rust", "go", "docker", "kubernetes", "notion", "github", "automation"]; keywordTags.forEach((kw) => { if (lowerText.includes(kw)) pushTag(kw); }); return tags.slice(0, 20); }, generateAIRepoCategory: async (repo, insight, settings) => { const categories = Array.isArray(settings?.categories) ? settings.categories.filter(Boolean) : []; if (!settings?.aiApiKey || !settings?.aiService || categories.length === 0) return ""; try { return await AIService.classify( `${repo.full_name || repo.name || ""} ${insight.title || ""}`, `${repo.description || ""}\n${insight.summary || ""}`, categories, settings ); } catch { return ""; } }, enrichRepo: async (repo, settings, context = {}) => { const enriched = { ...repo }; const prefix = GitHubExporter.normalizeText(repo.full_name || repo.name || "", 120) || "无标题"; let insight = { title: "", summary: "" }; try { const readme = await GitHubAPI.fetchRepoReadme(repo.full_name, settings?.token || ""); insight = GitHubExporter.extractReadmeInsight(readme); } catch { insight = { title: "", summary: "" }; } const defaultSuffix = insight.title || GitHubExporter.normalizeText(repo.description || "", 80); enriched.generatedTitle = GitHubExporter.composeTitleWithPrefix(prefix, defaultSuffix, 180); let inferredCategory = GitHubExporter.inferRepoCategoryHeuristic(repo, insight, settings?.categories || []); const canUseAI = !!(settings?.aiApiKey && settings?.aiService); const aiMaxItems = Number.isFinite(context.aiMaxItems) ? context.aiMaxItems : 20; if (canUseAI && (context.aiUsedCount || 0) < aiMaxItems) { const aiCategory = await GitHubExporter.generateAIRepoCategory(repo, insight, settings); if (aiCategory) inferredCategory = aiCategory; context.aiUsedCount = (context.aiUsedCount || 0) + 1; } enriched.inferredCategory = inferredCategory; enriched.inferredTags = GitHubExporter.inferRepoTags(repo, insight); enriched.readmeSummary = GitHubExporter.normalizeText(insight.summary || "", 1000); return enriched; }, // 构建 Notion 数据库属性 (repos/stars/forks) buildRepoProperties: (repo, sourceType = "Star") => { const titlePrefix = GitHubExporter.normalizeText(repo.full_name || repo.name || "无标题", 120) || "无标题"; const titleContent = GitHubExporter.composeTitleWithPrefix(titlePrefix, repo.generatedTitle || "", 2000); const summaryText = GitHubExporter.normalizeText(repo.readmeSummary || "", 1600); const descCandidate = GitHubExporter.normalizeText(repo.description || "", 1200); const description = [descCandidate, summaryText].filter(Boolean).join("\n\n").substring(0, 2000); const props = { "标题": { title: [{ text: { content: titleContent } }] }, "链接": { url: repo.html_url }, "描述": { rich_text: [{ text: { content: description } }] }, "语言": { rich_text: [{ text: { content: repo.language || "" } }] }, "Stars": { number: repo.stargazers_count || 0 }, "来源": { rich_text: [{ text: { content: "GitHub" } }] }, "来源类型": { rich_text: [{ text: { content: sourceType } }] }, }; const topicTags = Array.isArray(repo.topics) ? repo.topics.slice(0, 20) : []; const inferredTags = Array.isArray(repo.inferredTags) ? repo.inferredTags : []; const mergedTags = []; [...topicTags, ...inferredTags].forEach((tag) => { const clean = GitHubExporter.normalizeText(tag, 100); if (!clean) return; if (mergedTags.includes(clean)) return; mergedTags.push(clean); }); if (mergedTags.length > 0) { props["标签"] = { multi_select: mergedTags.slice(0, 20).map(t => ({ name: t })) }; } if (repo.inferredCategory) { props["分类"] = { rich_text: [{ text: { content: GitHubExporter.normalizeText(repo.inferredCategory, 300) } }] }; } if (repo.pushed_at) { props["更新时间"] = { date: { start: repo.pushed_at } }; } return props; }, // 构建 Gist 属性 buildGistProperties: (gist) => { const files = Object.keys(gist.files || {}); const title = gist.description || files[0] || "无标题 Gist"; const language = gist.files?.[files[0]]?.language || ""; return { "标题": { title: [{ text: { content: title.substring(0, 2000) } }] }, "链接": { url: gist.html_url }, "描述": { rich_text: [{ text: { content: `文件: ${files.join(", ")}`.substring(0, 2000) } }] }, "语言": { rich_text: [{ text: { content: language } }] }, "Stars": { number: 0 }, "来源": { rich_text: [{ text: { content: "GitHub" } }] }, "来源类型": { rich_text: [{ text: { content: "Gist" } }] }, "更新时间": gist.updated_at ? { date: { start: gist.updated_at } } : undefined, }; }, // 向后兼容:原 buildProperties 映射到 buildRepoProperties buildProperties: (repo) => GitHubExporter.buildRepoProperties(repo, "Star"), // 配置数据库属性结构 setupDatabaseProperties: async (databaseId, apiKey) => { const requiredProperties = { "标题": { typeName: "title", schema: { title: {} } }, "链接": { typeName: "url", schema: { url: {} } }, "描述": { typeName: "rich_text", schema: { rich_text: {} } }, "语言": { typeName: "rich_text", schema: { rich_text: {} } }, "Stars": { typeName: "number", schema: { number: { format: "number" } } }, "标签": { typeName: "multi_select", schema: { multi_select: { options: [] } } }, "来源": { typeName: "rich_text", schema: { rich_text: {} } }, "来源类型": { typeName: "rich_text", schema: { rich_text: {} } }, "更新时间": { typeName: "date", schema: { date: {} } }, "分类": { typeName: "rich_text", schema: { rich_text: {} } }, }; try { const database = await NotionAPI.request("GET", `/databases/${databaseId}`, null, apiKey); const existingProps = database.properties || {}; const propsToAdd = {}; const propsToUpdate = {}; const typeConflicts = []; for (const [name, { typeName, schema }] of Object.entries(requiredProperties)) { const existingProp = existingProps[name]; if (!existingProp) { if (typeName === "title") { // 特殊处理:title 属性需要重命名现有的 const existingTitle = Object.entries(existingProps).find(([_, prop]) => prop.type === "title"); if (existingTitle && existingTitle[0] !== name) { propsToUpdate[existingTitle[0]] = { name: name }; } } else { propsToAdd[name] = schema; } } else if (existingProp.type !== typeName) { typeConflicts.push({ name, expected: typeName, actual: existingProp.type }); } } if (typeConflicts.length > 0) { const details = typeConflicts.map(c => `"${c.name}": 期望 ${c.expected},实际 ${c.actual}`).join("; "); return { success: false, error: `属性类型不匹配: ${details}。请手动修改这些属性的类型。` }; } const allChanges = { ...propsToAdd, ...propsToUpdate }; if (Object.keys(allChanges).length > 0) { await NotionAPI.request("PATCH", `/databases/${databaseId}`, { properties: allChanges, }, apiKey); } return { success: true, added: Object.keys(propsToAdd), renamed: Object.keys(propsToUpdate) }; } catch (error) { return { success: false, error: error.message }; } }, // 通用导出方法 _exportItems: async (items, settings, sourceType, buildFn, isExportedFn, markExportedFn, getKeyFn, onProgress) => { const { apiKey, databaseId } = settings; const delay = Storage.get(CONFIG.STORAGE_KEYS.REQUEST_DELAY, CONFIG.DEFAULTS.requestDelay); const newItems = items.filter(item => !isExportedFn(getKeyFn(item))); if (newItems.length === 0) { return { total: items.length, exported: 0, failed: 0, message: `没有新的 ${sourceType} 需要导出` }; } let success = 0, failed = 0; const enrichContext = { aiUsedCount: 0, aiMaxItems: 20 }; for (let i = 0; i < newItems.length; i++) { const item = newItems[i]; const key = getKeyFn(item); const pct = Math.round(10 + (i / newItems.length) * 85); if (onProgress) onProgress(`正在导出 ${sourceType} (${i + 1}/${newItems.length}): ${key}`, pct); try { const enriched = sourceType === "Gist" ? item : await GitHubExporter.enrichRepo(item, settings, enrichContext); const properties = buildFn(enriched); // 清理 undefined 属性 for (const k of Object.keys(properties)) { if (properties[k] === undefined) delete properties[k]; } await NotionAPI.request("POST", "/pages", { parent: { database_id: databaseId }, properties, }, apiKey); markExportedFn(key); success++; } catch (e) { console.warn(`[GitHubExporter] 导出失败: ${key}`, e); failed++; } if (i < newItems.length - 1) { await new Promise(r => setTimeout(r, delay)); } } return { total: items.length, exported: success, failed, newCount: newItems.length }; }, // 导出 stars 到 Notion exportStars: async (settings, onProgress) => { const { apiKey, databaseId, username, token } = settings; if (!apiKey || !databaseId || !username) { throw new Error("请先配置 GitHub 用户名和 Notion 数据库"); } if (onProgress) onProgress("正在配置数据库结构...", 0); const setupResult = await GitHubExporter.setupDatabaseProperties(databaseId, apiKey); if (!setupResult.success) { throw new Error(`数据库配置失败: ${setupResult.error}`); } if (onProgress) onProgress("正在获取 GitHub Stars...", 5); const repos = await GitHubAPI.fetchStarredRepos(username, token); return GitHubExporter._exportItems( repos, settings, "Star", (r) => GitHubExporter.buildRepoProperties(r, "Star"), GitHubAPI.isExported, GitHubAPI.markExported, (r) => r.full_name, onProgress ); }, // 导出用户仓库到 Notion exportRepos: async (settings, onProgress) => { const { apiKey, databaseId, username, token } = settings; if (!apiKey || !databaseId || !username) { throw new Error("请先配置 GitHub 用户名和 Notion 数据库"); } if (onProgress) onProgress("正在配置数据库结构...", 0); await GitHubExporter.setupDatabaseProperties(databaseId, apiKey); if (onProgress) onProgress("正在获取 GitHub Repos...", 5); const repos = await GitHubAPI.fetchUserRepos(username, token); const ownRepos = repos.filter(r => !r.fork); return GitHubExporter._exportItems( ownRepos, settings, "Repo", (r) => GitHubExporter.buildRepoProperties(r, "Repo"), GitHubAPI.isExported, GitHubAPI.markExported, (r) => r.full_name, onProgress ); }, // 导出 fork 的仓库到 Notion exportForks: async (settings, onProgress) => { const { apiKey, databaseId, username, token } = settings; if (!apiKey || !databaseId || !username) { throw new Error("请先配置 GitHub 用户名和 Notion 数据库"); } if (onProgress) onProgress("正在配置数据库结构...", 0); await GitHubExporter.setupDatabaseProperties(databaseId, apiKey); if (onProgress) onProgress("正在获取 GitHub Forks...", 5); const forks = await GitHubAPI.fetchForkedRepos(username, token); return GitHubExporter._exportItems( forks, settings, "Fork", (r) => GitHubExporter.buildRepoProperties(r, "Fork"), GitHubAPI.isExported, GitHubAPI.markExported, (r) => r.full_name, onProgress ); }, // 导出 Gists 到 Notion exportGists: async (settings, onProgress) => { const { apiKey, databaseId, username, token } = settings; if (!apiKey || !databaseId || !username) { throw new Error("请先配置 GitHub 用户名和 Notion 数据库"); } if (onProgress) onProgress("正在配置数据库结构...", 0); await GitHubExporter.setupDatabaseProperties(databaseId, apiKey); if (onProgress) onProgress("正在获取 GitHub Gists...", 5); const gists = await GitHubAPI.fetchUserGists(username, token); return GitHubExporter._exportItems( gists, settings, "Gist", GitHubExporter.buildGistProperties, GitHubAPI.isGistExported, GitHubAPI.markGistExported, (g) => g.id, onProgress ); }, // 按用户选择的类型批量导出 exportAll: async (settings, onProgress) => { const types = GitHubAPI.getImportTypes(); const results = {}; const totalTypes = types.length; let typeIndex = 0; for (const type of types) { const typeProgress = (msg, pct) => { const overallPct = Math.round((typeIndex / totalTypes) * 100 + pct / totalTypes); if (onProgress) onProgress(`[${type}] ${msg}`, overallPct); }; try { switch (type) { case "stars": results.stars = await GitHubExporter.exportStars(settings, typeProgress); break; case "repos": results.repos = await GitHubExporter.exportRepos(settings, typeProgress); break; case "forks": results.forks = await GitHubExporter.exportForks(settings, typeProgress); break; case "gists": results.gists = await GitHubExporter.exportGists(settings, typeProgress); break; } } catch (e) { results[type] = { error: e.message }; } typeIndex++; } return results; }, // AI 分类已导出的 GitHub repos classifyRepos: async (settings, onProgress) => { const { apiKey, databaseId, aiApiKey, aiService, aiModel, aiBaseUrl, categories } = settings; if (!apiKey || !databaseId) throw new Error("请先配置 Notion 数据库"); if (!aiApiKey) throw new Error("请先配置 AI API Key"); if (onProgress) onProgress("正在获取待分类的仓库...", 0); // 查询数据库中未分类的条目 const response = await NotionAPI.request("POST", `/databases/${databaseId}/query`, { filter: { or: [ { property: "分类", rich_text: { is_empty: true } }, { property: "分类", rich_text: { equals: "" } }, ] }, page_size: 100, }, apiKey); const pages = response.results || []; if (pages.length === 0) { return { classified: 0, message: "没有待分类的仓库" }; } let classified = 0; for (let i = 0; i < pages.length; i++) { const page = pages[i]; const pct = Math.round((i / pages.length) * 100); const title = page.properties?.["标题"]?.title?.[0]?.text?.content || ""; const desc = page.properties?.["描述"]?.rich_text?.[0]?.text?.content || ""; const lang = page.properties?.["语言"]?.rich_text?.[0]?.text?.content || ""; const tags = (page.properties?.["标签"]?.multi_select || []).map(t => t.name).join(", "); if (onProgress) onProgress(`正在分类 (${i + 1}/${pages.length}): ${title}`, pct); try { const prompt = `请根据以下 GitHub 仓库信息,从这些分类中选择最合适的一个: [${categories.join(", ")}] 仓库名: ${title} 描述: ${desc} 语言: ${lang} 标签: ${tags} 只回复分类名,不要其他内容。`; const category = await AIService.request(prompt, { aiService, aiApiKey, aiModel: aiModel, aiBaseUrl, }); const matched = categories.find(c => category.trim().includes(c)) || category.trim(); await NotionAPI.request("PATCH", `/pages/${page.id}`, { properties: { "分类": { rich_text: [{ text: { content: matched } }] }, }, }, apiKey); classified++; } catch (e) { console.warn(`[GitHubExporter] 分类失败: ${title}`, e); } await new Promise(r => setTimeout(r, 500)); } return { classified, total: pages.length }; }, }; // =========================================== // 浏览器书签桥接模块 // =========================================== const BookmarkBridge = { _requestId: 0, _pendingRequests: {}, // 检测配套 Chrome 扩展是否已安装 isExtensionAvailable: () => { return !!document.querySelector('meta[name="ld-notion-ext"][content="ready"]'); }, // 发起书签请求 _request: (eventName, detail = {}) => { return new Promise((resolve, reject) => { if (!BookmarkBridge.isExtensionAvailable()) { const installUrl = InstallHelper.getBookmarkExtensionUrl(); reject(new Error(`未检测到 LD-Notion 书签桥接扩展。请先安装:${installUrl}`)); return; } const requestId = `req_${++BookmarkBridge._requestId}_${Date.now()}`; const timeout = setTimeout(() => { delete BookmarkBridge._pendingRequests[requestId]; reject(new Error("书签请求超时,请检查扩展是否正常运行。")); }, 10000); BookmarkBridge._pendingRequests[requestId] = { resolve, reject, timeout }; window.dispatchEvent(new CustomEvent(eventName, { detail: { requestId, ...detail } })); }); }, // 获取书签树 getBookmarkTree: () => { return BookmarkBridge._request("ld-notion-request-bookmarks"); }, // 获取指定文件夹的书签 getBookmarks: (folderId) => { return BookmarkBridge._request("ld-notion-request-bookmarks", { folderId }); }, // 搜索书签 searchBookmarks: (query) => { return BookmarkBridge._request("ld-notion-search-bookmarks", { query }); }, // 初始化响应监听器 init: () => { window.addEventListener("ld-notion-bookmarks-data", (event) => { const { requestId, success, data, error } = event.detail || {}; const pending = BookmarkBridge._pendingRequests[requestId]; if (!pending) return; clearTimeout(pending.timeout); delete BookmarkBridge._pendingRequests[requestId]; if (success) { pending.resolve(data); } else { pending.reject(new Error(error || "书签请求失败")); } }); }, }; // =========================================== // 浏览器书签导出到 Notion 模块 // =========================================== const BookmarkExporter = { _pageInsightCache: {}, // 展平书签树为列表,记录文件夹路径 flattenTree: (nodes, parentPath = "") => { const result = []; for (const node of nodes) { const currentPath = parentPath ? `${parentPath} / ${node.title}` : node.title; if (node.url) { // 书签项 result.push({ title: node.title || node.url, url: node.url, folderPath: parentPath, dateAdded: node.dateAdded ? new Date(node.dateAdded).toISOString() : null, id: node.id, }); } if (node.children) { result.push(...BookmarkExporter.flattenTree(node.children, currentPath)); } } return result; }, isHttpUrl: (url) => /^https?:\/\//i.test(url || ""), normalizeText: (text, maxLen = 280) => { if (!text) return ""; const normalized = String(text) .replace(/[\uFEFF\u200B-\u200D\u2060]/g, "") .replace(/\s+/g, " ") .trim(); return normalized.substring(0, maxLen); }, normalizeCharset: (charset) => { const value = String(charset || "").trim().replace(/^['"]|['"]$/g, "").toLowerCase(); if (!value) return ""; if (value === "utf8") return "utf-8"; if (value === "gbk" || value === "gb2312") return "gb18030"; if (value === "big-5") return "big5"; if (value === "shift-jis" || value === "sjis") return "shift_jis"; return value; }, extractCharsetFromHeaders: (responseHeaders) => { const headers = String(responseHeaders || ""); if (!headers) return ""; const match = headers.match(/content-type\s*:\s*[^\r\n]*charset\s*=\s*([^\s;"']+)/i); return BookmarkExporter.normalizeCharset(match?.[1] || ""); }, extractCharsetFromHtmlHead: (bytes) => { if (!(bytes instanceof Uint8Array) || bytes.length === 0) return ""; try { const head = new TextDecoder("latin1").decode(bytes.slice(0, 4096)); const charsetMatch = head.match(/]+charset\s*=\s*["']?([^\s"'>/]+)/i); if (charsetMatch?.[1]) { return BookmarkExporter.normalizeCharset(charsetMatch[1]); } const httpEquivMatch = head.match(/]+http-equiv\s*=\s*["']content-type["'][^>]*content\s*=\s*["'][^"']*charset\s*=\s*([^\s"';>]+)/i); return BookmarkExporter.normalizeCharset(httpEquivMatch?.[1] || ""); } catch { return ""; } }, getResponseBytes: (response) => { const raw = response?.response; if (raw instanceof ArrayBuffer) return new Uint8Array(raw); if (raw instanceof Uint8Array) return raw; return null; }, decodeHtmlFromResponse: (response) => { const fallbackText = String(response?.responseText || ""); const bytes = BookmarkExporter.getResponseBytes(response); if (!bytes || bytes.length === 0) return fallbackText; const headerCharset = BookmarkExporter.extractCharsetFromHeaders(response?.responseHeaders || ""); const htmlCharset = BookmarkExporter.extractCharsetFromHtmlHead(bytes); const candidates = [headerCharset, htmlCharset, "utf-8", "gb18030", "big5", "shift_jis"]; const tried = new Set(); let firstDecoded = ""; for (const candidate of candidates) { const charset = BookmarkExporter.normalizeCharset(candidate); if (!charset || tried.has(charset)) continue; tried.add(charset); try { const decoded = new TextDecoder(charset).decode(bytes); if (!decoded) continue; if (!firstDecoded) firstDecoded = decoded; if (!decoded.includes("\uFFFD")) return decoded; } catch { // ignore and continue trying next charset } } return firstDecoded || fallbackText; }, composeTitleWithPrefix: (prefix, candidate, maxLen = 180) => { const safePrefix = BookmarkExporter.normalizeText(prefix, maxLen); const safeCandidate = BookmarkExporter.normalizeText(candidate, maxLen); if (!safePrefix) return safeCandidate || "无标题书签"; if (!safeCandidate || safeCandidate === safePrefix) return safePrefix; if (safeCandidate.startsWith(`${safePrefix} - `) || safeCandidate.startsWith(`${safePrefix} · `)) { return safeCandidate.substring(0, maxLen); } return `${safePrefix} · ${safeCandidate}`.substring(0, maxLen); }, extractPageInsightFromHtml: (html, url) => { const parser = new DOMParser(); const doc = parser.parseFromString(html || "", "text/html"); const meta = (name) => { const el = doc.querySelector(`meta[property="${name}"], meta[name="${name}"]`); return el?.getAttribute("content") || ""; }; doc.querySelectorAll("script, style, noscript, template").forEach((node) => node.remove()); const title = BookmarkExporter.normalizeText( meta("og:title") || doc.querySelector("title")?.textContent || doc.querySelector("h1")?.textContent || meta("twitter:title") || "" , 180); const description = BookmarkExporter.normalizeText( meta("og:description") || meta("description") || meta("twitter:description") || "" , 260); const bodyText = BookmarkExporter.normalizeText(doc.body?.textContent || "", 600); const summary = description || bodyText; return { title, summary, siteName: BookmarkExporter.normalizeText(meta("og:site_name") || "", 80), sourceUrl: url, }; }, fetchPageInsight: (url) => { const cached = BookmarkExporter._pageInsightCache[url]; if (cached) return Promise.resolve(cached); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, timeout: 12000, responseType: "arraybuffer", headers: { "Accept": "text/html,application/xhtml+xml", }, onload: (response) => { if (response.status < 200 || response.status >= 300) { reject(new Error(`HTTP ${response.status}`)); return; } try { const html = BookmarkExporter.decodeHtmlFromResponse(response); const insight = BookmarkExporter.extractPageInsightFromHtml(html, url); BookmarkExporter._pageInsightCache[url] = insight; resolve(insight); } catch (e) { reject(e); } }, ontimeout: () => reject(new Error("页面读取超时")), onerror: () => reject(new Error("页面读取失败")), }); }); }, generateAISummary: async (bookmark, insight, settings) => { if (!settings?.aiApiKey || !settings?.aiService) return null; const prompt = `请根据以下网页信息生成书签标题和摘要,要求:\n1) 标题 30 字以内\n2) 摘要 90 字以内\n3) 使用中文\n4) 仅返回 JSON,不要其他内容\n\nJSON 格式:{"title":"...","summary":"..."}\n\n网页 URL:${bookmark.url}\n原始标题:${bookmark.title || ""}\n页面标题:${insight.title || ""}\n页面摘要:${insight.summary || ""}`; try { const response = await AIService.requestChat(prompt, settings, 220); const jsonMatch = response.match(/\{[\s\S]*\}/); if (!jsonMatch) return null; const data = JSON.parse(jsonMatch[0]); return { title: BookmarkExporter.normalizeText(data.title || "", 120), summary: BookmarkExporter.normalizeText(data.summary || "", 180), }; } catch { return null; } }, inferCategoryHeuristic: (bookmark, insight, categories = []) => { const available = (categories || []).map(c => String(c || "").trim()).filter(Boolean); if (available.length === 0) return ""; const text = `${bookmark.folderPath || ""} ${bookmark.title || ""} ${insight.title || ""} ${insight.summary || ""} ${bookmark.url || ""}`.toLowerCase(); for (const cat of available) { if (text.includes(cat.toLowerCase())) { return cat; } } const rules = [ { keys: ["github", "gitlab", "repo", "docker", "k8s", "linux", "dev", "code", "programming", "技术", "开发", "编程"], hints: ["技术", "开发", "编程"] }, { keys: ["news", "blog", "article", "文章", "博客", "资讯"], hints: ["分享", "资源"] }, { keys: ["stack", "stackoverflow", "ask", "question", "qa", "问答", "问题"], hints: ["问答"] }, { keys: ["life", "travel", "food", "movie", "music", "生活", "日常", "旅游", "美食"], hints: ["生活"] }, { keys: ["resource", "docs", "tutorial", "guide", "文档", "教程", "手册", "资源"], hints: ["资源"] }, ]; for (const rule of rules) { if (!rule.keys.some(k => text.includes(k))) continue; const matched = available.find(cat => rule.hints.some(h => cat.includes(h))); if (matched) return matched; } const fallback = available.find(cat => cat.includes("其他")); return fallback || available[available.length - 1]; }, inferTags: (bookmark, insight) => { const tags = []; const host = (() => { try { return new URL(bookmark.url).hostname.replace(/^www\./, ""); } catch { return ""; } })(); if (host) tags.push(host); if (bookmark.folderPath) { const firstFolder = BookmarkExporter.normalizeText(String(bookmark.folderPath).split("/")[0] || "", 40); if (firstFolder) tags.push(firstFolder); } if (insight.siteName) { tags.push(BookmarkExporter.normalizeText(insight.siteName, 40)); } const uniq = []; for (const t of tags) { const clean = BookmarkExporter.normalizeText(t, 80); if (!clean) continue; if (uniq.includes(clean)) continue; uniq.push(clean); if (uniq.length >= 5) break; } return uniq; }, generateAICategory: async (bookmark, insight, settings) => { const categories = Array.isArray(settings?.categories) ? settings.categories.filter(Boolean) : []; if (!settings?.aiApiKey || !settings?.aiService || categories.length === 0) return ""; try { return await AIService.classify( insight.title || bookmark.title || "", insight.summary || "", categories, settings ); } catch { return ""; } }, enrichBookmark: async (bookmark, settings, context = {}) => { const enriched = { ...bookmark }; const prefix = BookmarkExporter.normalizeText(bookmark.title || "无标题书签", 120) || "无标题书签"; const fallbackTitle = BookmarkExporter.composeTitleWithPrefix(prefix, "", 180); if (!BookmarkExporter.isHttpUrl(bookmark.url)) { enriched.generatedTitle = fallbackTitle; enriched.generatedSummary = "非网页链接,跳过页面摘要"; enriched.inferredCategory = BookmarkExporter.inferCategoryHeuristic(bookmark, { title: "", summary: "" }, settings?.categories || []); enriched.inferredTags = BookmarkExporter.inferTags(bookmark, { siteName: "" }); return enriched; } try { const insight = await BookmarkExporter.fetchPageInsight(bookmark.url); enriched.generatedTitle = BookmarkExporter.composeTitleWithPrefix(prefix, insight.title || "", 180); enriched.generatedSummary = insight.summary || ""; let inferredCategory = BookmarkExporter.inferCategoryHeuristic(bookmark, insight, settings?.categories || []); enriched.inferredTags = BookmarkExporter.inferTags(bookmark, insight); const canUseAI = !!(settings?.aiApiKey && settings?.aiService); const aiMaxItems = Number.isFinite(context.aiMaxItems) ? context.aiMaxItems : 20; if (canUseAI && (context.aiUsedCount || 0) < aiMaxItems) { const aiResult = await BookmarkExporter.generateAISummary(bookmark, insight, settings); if (aiResult?.title) { enriched.generatedTitle = BookmarkExporter.composeTitleWithPrefix(prefix, aiResult.title, 180); } if (aiResult?.summary) { enriched.generatedSummary = aiResult.summary; } const aiCategory = await BookmarkExporter.generateAICategory(bookmark, insight, settings); if (aiCategory) { inferredCategory = aiCategory; } context.aiUsedCount = (context.aiUsedCount || 0) + 1; } enriched.inferredCategory = inferredCategory; } catch { enriched.generatedTitle = fallbackTitle; enriched.generatedSummary = ""; enriched.inferredCategory = BookmarkExporter.inferCategoryHeuristic(bookmark, { title: "", summary: "" }, settings?.categories || []); enriched.inferredTags = BookmarkExporter.inferTags(bookmark, { siteName: "" }); } return enriched; }, // 构建 Notion 属性 buildProperties: (bookmark) => { const title = BookmarkExporter.normalizeText(bookmark.generatedTitle || bookmark.title || "无标题书签", 2000) || "无标题书签"; const summary = BookmarkExporter.normalizeText(bookmark.generatedSummary || "", 1900); const props = { "标题": { title: [{ text: { content: title } }] }, "链接": { url: bookmark.url }, "来源": { rich_text: [{ text: { content: "浏览器书签" } }] }, "来源类型": { rich_text: [{ text: { content: "书签" } }] }, "书签路径": { rich_text: [{ text: { content: (bookmark.folderPath || "").substring(0, 2000) } }] }, }; if (summary) { props["描述"] = { rich_text: [{ text: { content: summary } }] }; } if (bookmark.inferredCategory) { props["分类"] = { rich_text: [{ text: { content: BookmarkExporter.normalizeText(bookmark.inferredCategory, 300) } }] }; } const tags = Array.isArray(bookmark.inferredTags) ? bookmark.inferredTags : []; if (tags.length > 0) { props["标签"] = { multi_select: tags .map(tag => BookmarkExporter.normalizeText(tag, 100)) .filter(Boolean) .map(name => ({ name })) .slice(0, 8) }; } if (bookmark.dateAdded) { props["收藏时间"] = { date: { start: bookmark.dateAdded } }; } return props; }, // 配置数据库属性 setupDatabaseProperties: async (databaseId, apiKey) => { const requiredProperties = { "标题": { typeName: "title", schema: { title: {} } }, "链接": { typeName: "url", schema: { url: {} } }, "来源": { typeName: "rich_text", schema: { rich_text: {} } }, "来源类型": { typeName: "rich_text", schema: { rich_text: {} } }, "标签": { typeName: "multi_select", schema: { multi_select: { options: [] } } }, "书签路径": { typeName: "rich_text", schema: { rich_text: {} } }, "收藏时间": { typeName: "date", schema: { date: {} } }, "分类": { typeName: "rich_text", schema: { rich_text: {} } }, "描述": { typeName: "rich_text", schema: { rich_text: {} } }, }; try { const database = await NotionAPI.request("GET", `/databases/${databaseId}`, null, apiKey); const existingProps = database.properties || {}; const propsToAdd = {}; const propsToUpdate = {}; for (const [name, { typeName, schema }] of Object.entries(requiredProperties)) { const existingProp = existingProps[name]; if (!existingProp) { if (typeName === "title") { const existingTitle = Object.entries(existingProps).find(([_, prop]) => prop.type === "title"); if (existingTitle && existingTitle[0] !== name) { propsToUpdate[existingTitle[0]] = { name: name }; } } else { propsToAdd[name] = schema; } } } const allChanges = { ...propsToAdd, ...propsToUpdate }; if (Object.keys(allChanges).length > 0) { await NotionAPI.request("PATCH", `/databases/${databaseId}`, { properties: allChanges, }, apiKey); } return { success: true, added: Object.keys(propsToAdd) }; } catch (error) { return { success: false, error: error.message }; } }, // 获取已导出的书签集合 getExported: () => { try { return JSON.parse(Storage.get(CONFIG.STORAGE_KEYS.BOOKMARK_EXPORTED, "{}")); } catch { return {}; } }, markExported: (bookmarkUrl) => { const exported = BookmarkExporter.getExported(); exported[bookmarkUrl] = Date.now(); Storage.set(CONFIG.STORAGE_KEYS.BOOKMARK_EXPORTED, JSON.stringify(exported)); }, isExported: (bookmarkUrl) => { return !!BookmarkExporter.getExported()[bookmarkUrl]; }, // 导出书签到 Notion exportBookmarks: async (settings, onProgress) => { const { apiKey, databaseId, bookmarks } = settings; if (!apiKey || !databaseId) { throw new Error("请先配置 Notion API Key 和数据库"); } if (onProgress) onProgress("正在配置数据库结构...", 0); const setupResult = await BookmarkExporter.setupDatabaseProperties(databaseId, apiKey); if (!setupResult.success) { throw new Error(`数据库配置失败: ${setupResult.error}`); } // 过滤已导出的 const dedupStrict = Utils.isBookmarkDedupStrict(); const newBookmarks = dedupStrict ? bookmarks.filter(b => !BookmarkExporter.isExported(b.url)) : bookmarks.slice(); if (newBookmarks.length === 0) { return { total: bookmarks.length, exported: 0, message: "没有新的书签需要导出" }; } const delay = Storage.get(CONFIG.STORAGE_KEYS.REQUEST_DELAY, CONFIG.DEFAULTS.requestDelay); let success = 0, failed = 0; const enrichContext = { aiUsedCount: 0, aiMaxItems: 20 }; for (let i = 0; i < newBookmarks.length; i++) { const bm = newBookmarks[i]; const pct = Math.round(5 + (i / newBookmarks.length) * 90); if (onProgress) onProgress(`正在导出 (${i + 1}/${newBookmarks.length}): ${bm.title}`, pct); try { const enriched = await BookmarkExporter.enrichBookmark(bm, settings, enrichContext); const properties = BookmarkExporter.buildProperties(enriched); await NotionAPI.request("POST", "/pages", { parent: { database_id: databaseId }, properties, }, apiKey); BookmarkExporter.markExported(bm.url); success++; } catch (e) { console.warn(`[BookmarkExporter] 导出失败: ${bm.url}`, e); failed++; } if (i < newBookmarks.length - 1) { await new Promise(r => setTimeout(r, delay)); } } return { total: bookmarks.length, exported: success, failed, newCount: newBookmarks.length }; }, }; // 初始化书签桥接 BookmarkBridge.init(); // 监听扩展 Popup 快捷操作(仅在 Chrome 扩展版中生效) window.addEventListener("ld-notion-popup-action", (event) => { const { action } = event.detail || {}; if (action === "set-bookmark-source") { const source = event.detail?.source === "github" ? "github" : "linuxdo"; Storage.set(CONFIG.STORAGE_KEYS.BOOKMARK_SOURCE, source); if (typeof UI !== "undefined" && UI.panel && UI.refs) { if (typeof UI.switchBookmarkSource === "function") { UI.switchBookmarkSource(source); } else { UI.applyBookmarkSourceUI(source); } const sourceToggle = UI.refs.sourceSettingsToggle || UI.panel.querySelector("#ldb-source-settings-toggle"); const sourceContent = UI.refs.sourceSettingsContent || UI.panel.querySelector("#ldb-source-settings-content"); const sourceArrow = UI.refs.sourceSettingsArrow || UI.panel.querySelector("#ldb-source-settings-arrow"); if (sourceToggle && sourceContent?.classList.contains("collapsed")) { sourceToggle.click(); } else if (sourceContent && sourceArrow) { sourceContent.classList.remove("collapsed"); sourceArrow.textContent = "▼"; } } return; } const cmdMap = { "import-bookmarks": "导入浏览器书签", "import-github": "导入GitHub收藏", }; const cmd = cmdMap[action]; if (!cmd) return; const input = document.querySelector("#ldb-chat-input"); if (input && typeof ChatUI !== "undefined" && ChatUI.sendMessage) { input.value = cmd; ChatUI.sendMessage(); } }); // =========================================== // UI 设计系统(Design Tokens + 一次性样式注入) // =========================================== const StyleManager = { injectOnce: (styleId, cssText) => { if (!styleId || !cssText) return null; const root = document.head || document.documentElement; if (!root) return null; const existing = document.getElementById(styleId); if (existing) return existing; const style = document.createElement("style"); style.id = styleId; style.setAttribute("data-ldb-style", styleId); style.textContent = cssText; root.appendChild(style); return style; }, }; const DesignSystem = { STYLE_IDS: { BASE: "ldb-ui-base", CHAT: "ldb-ui-chat", NOTION: "ldb-ui-notion", LINUX_DO: "ldb-ui-linux-do", GENERIC: "ldb-ui-generic", }, // 主题管理 _theme: "auto", _mediaQuery: null, initTheme: () => { DesignSystem._theme = Storage.get(CONFIG.STORAGE_KEYS.THEME_PREFERENCE, "auto"); DesignSystem._applyTheme(); // 监听系统主题变化(auto 模式下自动跟随) DesignSystem._mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); DesignSystem._mediaQuery.addEventListener("change", () => { if (DesignSystem._theme === "auto") DesignSystem._applyTheme(); }); }, setTheme: (theme) => { DesignSystem._theme = theme; Storage.set(CONFIG.STORAGE_KEYS.THEME_PREFERENCE, theme); DesignSystem._applyTheme(); }, getEffectiveTheme: () => { if (DesignSystem._theme === "auto") { return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } return DesignSystem._theme; }, _applyTheme: () => { const effective = DesignSystem.getEffectiveTheme(); document.querySelectorAll("[data-ldb-root]").forEach(el => { el.setAttribute("data-ldb-theme", effective); }); // 同步所有主题切换按钮 document.querySelectorAll(".ldb-theme-btn").forEach(btn => { btn.textContent = effective === "dark" ? "☀️" : "🌙"; btn.title = effective === "dark" ? "切换亮色模式" : "切换暗色模式"; }); }, toggleTheme: () => { const effective = DesignSystem.getEffectiveTheme(); DesignSystem.setTheme(effective === "dark" ? "light" : "dark"); }, ensureBase: () => { StyleManager.injectOnce(DesignSystem.STYLE_IDS.BASE, DesignSystem.getBaseCSS()); }, ensureChat: () => { StyleManager.injectOnce(DesignSystem.STYLE_IDS.CHAT, DesignSystem.getChatCSS()); }, getBaseCSS: () => ` /* LDB_UI_TOKENS */ .ldb-panel, .ldb-notion-panel, .gclip-panel, .ldb-notion-float-btn, .ldb-mini-btn, .gclip-float-btn, .ldb-undo-toast { --ldb-ui-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --ldb-ui-radius: 14px; --ldb-ui-radius-sm: 10px; --ldb-ui-radius-xs: 8px; --ldb-ui-shadow: 0 18px 55px rgba(2, 6, 23, 0.22); --ldb-ui-shadow-sm: 0 10px 26px rgba(2, 6, 23, 0.16); --ldb-ui-text: #0f172a; --ldb-ui-muted: #64748b; --ldb-ui-border: rgba(15, 23, 42, 0.14); --ldb-ui-surface: rgba(255, 255, 255, 0.94); --ldb-ui-surface-2: rgba(248, 250, 252, 0.94); --ldb-ui-surface-3: rgba(241, 245, 249, 0.94); --ldb-ui-accent: #2563eb; --ldb-ui-accent-2: #7c3aed; --ldb-ui-success: #16a34a; --ldb-ui-warning: #d97706; --ldb-ui-danger: #dc2626; --ldb-ui-focus-ring: rgba(37, 99, 235, 0.35); --ldb-ui-backdrop: rgba(2, 6, 23, 0.35); font-family: var(--ldb-ui-font); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } /* 暗色主题 — 通过 data-ldb-theme 属性触发 */ [data-ldb-theme="dark"].ldb-panel, [data-ldb-theme="dark"].ldb-notion-panel, [data-ldb-theme="dark"].gclip-panel, [data-ldb-theme="dark"].ldb-notion-float-btn, [data-ldb-theme="dark"].ldb-mini-btn, [data-ldb-theme="dark"].gclip-float-btn, [data-ldb-theme="dark"].ldb-undo-toast, [data-ldb-theme="dark"] .ldb-panel, [data-ldb-theme="dark"] .ldb-notion-panel, [data-ldb-theme="dark"] .gclip-panel, [data-ldb-theme="dark"] .ldb-notion-float-btn, [data-ldb-theme="dark"] .ldb-mini-btn, [data-ldb-theme="dark"] .gclip-float-btn, [data-ldb-theme="dark"] .ldb-undo-toast { --ldb-ui-text: #e5e7eb; --ldb-ui-muted: #9ca3af; --ldb-ui-border: rgba(148, 163, 184, 0.22); --ldb-ui-surface: rgba(17, 24, 39, 0.92); --ldb-ui-surface-2: rgba(15, 23, 42, 0.92); --ldb-ui-surface-3: rgba(2, 6, 23, 0.60); --ldb-ui-accent: #60a5fa; --ldb-ui-accent-2: #c4b5fd; --ldb-ui-focus-ring: rgba(96, 165, 250, 0.35); --ldb-ui-backdrop: rgba(0, 0, 0, 0.45); } /* 保留 prefers-color-scheme 作为 auto 模式的回退 */ @media (prefers-color-scheme: dark) { .ldb-panel:not([data-ldb-theme]), .ldb-notion-panel:not([data-ldb-theme]), .gclip-panel:not([data-ldb-theme]), .ldb-notion-float-btn:not([data-ldb-theme]), .ldb-mini-btn:not([data-ldb-theme]), .gclip-float-btn:not([data-ldb-theme]), .ldb-undo-toast:not([data-ldb-theme]) { --ldb-ui-text: #e5e7eb; --ldb-ui-muted: #9ca3af; --ldb-ui-border: rgba(148, 163, 184, 0.22); --ldb-ui-surface: rgba(17, 24, 39, 0.92); --ldb-ui-surface-2: rgba(15, 23, 42, 0.92); --ldb-ui-surface-3: rgba(2, 6, 23, 0.60); --ldb-ui-accent: #60a5fa; --ldb-ui-accent-2: #c4b5fd; --ldb-ui-focus-ring: rgba(96, 165, 250, 0.35); --ldb-ui-backdrop: rgba(0, 0, 0, 0.45); } } .ldb-panel, .ldb-notion-panel, .gclip-panel, .ldb-undo-toast { color: var(--ldb-ui-text); } .ldb-panel *, .ldb-notion-panel *, .gclip-panel *, .ldb-undo-toast * { box-sizing: border-box; } .ldb-panel a, .ldb-notion-panel a, .gclip-panel a { color: var(--ldb-ui-accent); text-decoration: none; } .ldb-panel a:hover, .ldb-notion-panel a:hover, .gclip-panel a:hover { text-decoration: underline; } .ldb-panel button, .ldb-notion-panel button, .gclip-panel button, .ldb-notion-float-btn, .ldb-mini-btn, .gclip-float-btn { font-family: inherit; } .ldb-panel input, .ldb-panel select, .ldb-panel textarea, .ldb-notion-panel input, .ldb-notion-panel select, .ldb-notion-panel textarea, .gclip-panel input, .gclip-panel select, .gclip-panel textarea { font-family: inherit; color: var(--ldb-ui-text); background: var(--ldb-ui-surface-2); border: 1px solid var(--ldb-ui-border); border-radius: var(--ldb-ui-radius-xs); padding: 8px 10px; outline: none; } .ldb-panel input::placeholder, .ldb-panel textarea::placeholder, .ldb-notion-panel input::placeholder, .ldb-notion-panel textarea::placeholder, .gclip-panel input::placeholder, .gclip-panel textarea::placeholder { color: var(--ldb-ui-muted); } .ldb-panel button:focus-visible, .ldb-panel input:focus-visible, .ldb-panel select:focus-visible, .ldb-panel textarea:focus-visible, .ldb-notion-panel button:focus-visible, .ldb-notion-panel input:focus-visible, .ldb-notion-panel select:focus-visible, .ldb-notion-panel textarea:focus-visible, .gclip-panel button:focus-visible, .gclip-panel input:focus-visible, .gclip-panel select:focus-visible, .gclip-panel textarea:focus-visible, .ldb-notion-float-btn:focus-visible, .ldb-mini-btn:focus-visible, .gclip-float-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--ldb-ui-focus-ring); } .ldb-panel, .ldb-notion-panel, .gclip-panel { background: var(--ldb-ui-surface); border: 1px solid var(--ldb-ui-border); border-radius: var(--ldb-ui-radius); box-shadow: var(--ldb-ui-shadow); backdrop-filter: blur(10px); } .ldb-header, .ldb-notion-header, .gclip-panel-header { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 14px; background: rgba(148, 163, 184, 0.10); border-bottom: 1px solid var(--ldb-ui-border); } .ldb-header h3, .ldb-notion-header h3 { margin: 0; font-size: 14px; font-weight: 700; color: var(--ldb-ui-text); letter-spacing: 0.2px; } .ldb-header-btn, .ldb-notion-header-btn, .gclip-panel-header .close-btn { width: 30px; height: 30px; border-radius: 10px; border: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.12); color: var(--ldb-ui-text); cursor: pointer; user-select: none; display: inline-flex; align-items: center; justify-content: center; padding: 0; line-height: 1; } .ldb-header-btn:hover, .ldb-notion-header-btn:hover, .gclip-panel-header .close-btn:hover { background: rgba(148, 163, 184, 0.18); } .ldb-btn, .gclip-btn { border: 1px solid rgba(37, 99, 235, 0.35); background: linear-gradient(135deg, var(--ldb-ui-accent) 0%, var(--ldb-ui-accent-2) 100%); color: #fff; border-radius: 12px; padding: 8px 12px; cursor: pointer; user-select: none; font-weight: 650; } .ldb-btn:disabled, .gclip-btn:disabled { opacity: 0.65; cursor: not-allowed; } .ldb-btn-secondary, .gclip-btn-secondary { border: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.12); color: var(--ldb-ui-text); font-weight: 600; } .ldb-btn-warning { border: 1px solid rgba(217, 119, 6, 0.35); background: linear-gradient(135deg, #f59e0b 0%, var(--ldb-ui-warning) 100%); color: #fff; } .ldb-btn-danger { border: 1px solid rgba(220, 38, 38, 0.35); background: linear-gradient(135deg, #ef4444 0%, var(--ldb-ui-danger) 100%); color: #fff; } .ldb-section-title { font-size: 13px; font-weight: 700; margin-bottom: 10px; color: var(--ldb-ui-text); } .ldb-section { padding: 12px 0; } .ldb-body, .ldb-notion-body, .gclip-panel-body { padding: 14px; } .ldb-input-group, .gclip-field, .ldb-form-group { margin-bottom: 12px; } .ldb-label, .gclip-field label, .ldb-form-group label { display: block; margin-bottom: 6px; font-size: 12px; font-weight: 650; color: var(--ldb-ui-muted); } .ldb-input, .ldb-select { width: 100%; } .ldb-tip { margin-top: 6px; font-size: 12px; color: var(--ldb-ui-muted); } .ldb-divider { height: 1px; background: var(--ldb-ui-border); margin: 12px 0; } .ldb-status { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.10); color: var(--ldb-ui-text); font-size: 12px; line-height: 1.5; } .ldb-status.success { border-color: rgba(22, 163, 74, 0.35); background: rgba(22, 163, 74, 0.12); } .ldb-status.error { border-color: rgba(220, 38, 38, 0.35); background: rgba(220, 38, 38, 0.12); } .ldb-status.info { border-color: rgba(37, 99, 235, 0.30); background: rgba(37, 99, 235, 0.10); } .ldb-status-close { width: 26px; height: 26px; border-radius: 10px; border: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.10); color: var(--ldb-ui-text); cursor: pointer; flex: 0 0 auto; line-height: 1; } .ldb-status-close:hover { background: rgba(148, 163, 184, 0.18); } @media (prefers-reduced-motion: reduce) { .ldb-panel, .ldb-notion-panel, .gclip-panel, .ldb-undo-toast, .ldb-panel *, .ldb-notion-panel *, .gclip-panel *, .ldb-notion-float-btn, .ldb-mini-btn, .gclip-float-btn { transition: none !important; animation: none !important; scroll-behavior: auto !important; } } `, getChatCSS: () => ` /* LDB_UI_CHAT */ .ldb-panel .ldb-chat-container, .ldb-notion-panel .ldb-chat-container { height: 280px; overflow-y: auto; background: var(--ldb-ui-surface-3); border: 1px solid var(--ldb-ui-border); border-radius: var(--ldb-ui-radius-sm); padding: 12px; margin-bottom: 12px; } .ldb-panel .ldb-chat-container::-webkit-scrollbar, .ldb-notion-panel .ldb-chat-container::-webkit-scrollbar { width: 6px; } .ldb-panel .ldb-chat-container::-webkit-scrollbar-track, .ldb-notion-panel .ldb-chat-container::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.06); border-radius: 3px; } .ldb-panel .ldb-chat-container::-webkit-scrollbar-thumb, .ldb-notion-panel .ldb-chat-container::-webkit-scrollbar-thumb { background: rgba(148, 163, 184, 0.35); border-radius: 3px; } @media (prefers-color-scheme: dark) { .ldb-panel:not([data-ldb-theme]) .ldb-chat-container::-webkit-scrollbar-track, .ldb-notion-panel:not([data-ldb-theme]) .ldb-chat-container::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.06); } .ldb-panel:not([data-ldb-theme]) .ldb-chat-container::-webkit-scrollbar-thumb, .ldb-notion-panel:not([data-ldb-theme]) .ldb-chat-container::-webkit-scrollbar-thumb { background: rgba(148, 163, 184, 0.30); } } [data-ldb-theme="dark"] .ldb-chat-container::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.06); } [data-ldb-theme="dark"] .ldb-chat-container::-webkit-scrollbar-thumb { background: rgba(148, 163, 184, 0.30); } .ldb-panel .ldb-chat-welcome, .ldb-notion-panel .ldb-chat-welcome { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; text-align: center; color: var(--ldb-ui-muted); gap: 10px; } .ldb-panel .ldb-chat-welcome-icon, .ldb-notion-panel .ldb-chat-welcome-icon { font-size: 44px; line-height: 1; } .ldb-panel .ldb-chat-welcome-text, .ldb-notion-panel .ldb-chat-welcome-text { font-size: 13px; line-height: 1.6; } .ldb-panel .ldb-chat-welcome-text small, .ldb-notion-panel .ldb-chat-welcome-text small { color: var(--ldb-ui-muted); opacity: 0.9; } .ldb-panel .ldb-chat-chips, .ldb-notion-panel .ldb-chat-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; justify-content: center; } .ldb-panel .ldb-chat-chip, .ldb-notion-panel .ldb-chat-chip { padding: 6px 12px; background: rgba(148, 163, 184, 0.14); border: 1px solid var(--ldb-ui-border); border-radius: 999px; color: var(--ldb-ui-text); font-size: 12px; cursor: pointer; } .ldb-panel .ldb-chat-chip:hover, .ldb-notion-panel .ldb-chat-chip:hover { background: rgba(37, 99, 235, 0.16); border-color: rgba(37, 99, 235, 0.28); } .ldb-panel .ldb-chat-message, .ldb-notion-panel .ldb-chat-message { margin-bottom: 12px; display: flex; flex-direction: column; } .ldb-panel .ldb-chat-message.user, .ldb-notion-panel .ldb-chat-message.user { align-items: flex-end; } .ldb-panel .ldb-chat-message.assistant, .ldb-notion-panel .ldb-chat-message.assistant { align-items: flex-start; } .ldb-panel .ldb-chat-bubble, .ldb-notion-panel .ldb-chat-bubble { max-width: 85%; padding: 10px 12px; border-radius: 12px; font-size: 13px; line-height: 1.6; word-break: break-word; border: 1px solid transparent; } .ldb-panel .ldb-chat-bubble.user, .ldb-notion-panel .ldb-chat-bubble.user { background: linear-gradient(135deg, var(--ldb-ui-accent) 0%, var(--ldb-ui-accent-2) 100%); color: #fff; border-bottom-right-radius: 6px; } .ldb-panel .ldb-chat-bubble.assistant, .ldb-notion-panel .ldb-chat-bubble.assistant { background: var(--ldb-ui-surface-2); color: var(--ldb-ui-text); border: 1px solid var(--ldb-ui-border); border-bottom-left-radius: 6px; } .ldb-panel .ldb-chat-bubble.processing, .ldb-notion-panel .ldb-chat-bubble.processing { opacity: 0.85; } .ldb-panel .ldb-chat-bubble.processing .ldb-typing-dots, .ldb-notion-panel .ldb-chat-bubble.processing .ldb-typing-dots { display: inline-flex; gap: 4px; margin-left: 6px; vertical-align: middle; } .ldb-panel .ldb-chat-bubble.processing .ldb-typing-dots span, .ldb-notion-panel .ldb-chat-bubble.processing .ldb-typing-dots span { width: 6px; height: 6px; border-radius: 50%; background: rgba(148, 163, 184, 0.9); display: inline-block; animation: ldb-typing 1.1s infinite ease-in-out; } .ldb-panel .ldb-chat-bubble.processing .ldb-typing-dots span:nth-child(2), .ldb-notion-panel .ldb-chat-bubble.processing .ldb-typing-dots span:nth-child(2) { animation-delay: 0.2s; } .ldb-panel .ldb-chat-bubble.processing .ldb-typing-dots span:nth-child(3), .ldb-notion-panel .ldb-chat-bubble.processing .ldb-typing-dots span:nth-child(3) { animation-delay: 0.4s; } @keyframes ldb-typing { 0%, 80%, 100% { transform: translateY(0); opacity: 0.6; } 40% { transform: translateY(-3px); opacity: 1; } } .ldb-panel .ldb-chat-input-container, .ldb-notion-panel .ldb-chat-input-container { display: flex; gap: 8px; align-items: flex-end; margin-top: 10px; } .ldb-panel .ldb-chat-input, .ldb-notion-panel .ldb-chat-input { flex: 1; resize: none; min-height: 36px; max-height: 80px; line-height: 1.5; } .ldb-panel .ldb-chat-send-btn, .ldb-notion-panel .ldb-chat-send-btn { padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(37, 99, 235, 0.35); background: linear-gradient(135deg, var(--ldb-ui-accent) 0%, var(--ldb-ui-accent-2) 100%); color: #fff; cursor: pointer; user-select: none; } .ldb-panel .ldb-chat-send-btn:disabled, .ldb-notion-panel .ldb-chat-send-btn:disabled { opacity: 0.65; cursor: not-allowed; } .ldb-panel .ldb-chat-actions, .ldb-notion-panel .ldb-chat-actions { display: flex; gap: 8px; margin-top: 10px; } .ldb-panel .ldb-chat-action-btn, .ldb-notion-panel .ldb-chat-action-btn { padding: 6px 10px; border-radius: 10px; border: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.12); color: var(--ldb-ui-text); cursor: pointer; user-select: none; font-size: 12px; } .ldb-panel .ldb-chat-action-btn:hover, .ldb-notion-panel .ldb-chat-action-btn:hover { background: rgba(148, 163, 184, 0.18); } .ldb-panel .ldb-chat-settings-toggle, .ldb-notion-panel .ldb-chat-settings-toggle { display: flex; justify-content: space-between; align-items: center; cursor: pointer; user-select: none; margin-top: 10px; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.10); } .ldb-panel .ldb-chat-settings-content.collapsed, .ldb-notion-panel .ldb-chat-settings-content.collapsed { display: none; } `, }; // =========================================== // 面板拉伸工具 // =========================================== const PanelResize = { _stylesInjected: false, injectStyles: () => { if (PanelResize._stylesInjected) return; PanelResize._stylesInjected = true; const style = document.createElement("style"); style.textContent = ` .ldb-resize-handle { position: absolute; z-index: 10; } .ldb-resize-handle-l { left: -3px; top: 0; width: 6px; height: 100%; cursor: ew-resize; } .ldb-resize-handle-t { left: 0; top: -3px; width: 100%; height: 6px; cursor: ns-resize; } .ldb-resize-handle-b { left: 0; bottom: -3px; width: 100%; height: 6px; cursor: ns-resize; } .ldb-resize-handle-tl { left: -3px; top: -3px; width: 12px; height: 12px; cursor: nwse-resize; } .ldb-resize-handle-bl { left: -3px; bottom: -3px; width: 12px; height: 12px; cursor: nesw-resize; } `; document.head.appendChild(style); }, makeResizable: (element, options = {}) => { const { edges = ["l", "t"], storageKey = null, minWidth = 280, minHeight = 200, maxWidth = 800, } = options; PanelResize.injectStyles(); edges.forEach(edge => { const handle = document.createElement("div"); handle.className = `ldb-resize-handle ldb-resize-handle-${edge}`; element.appendChild(handle); handle.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const startY = e.clientY; const startWidth = element.offsetWidth; const startHeight = element.offsetHeight; document.body.style.userSelect = "none"; element.style.transition = "none"; const onMove = (ev) => { if (edge.includes("l")) { const dx = startX - ev.clientX; element.style.width = Math.max(minWidth, Math.min(maxWidth, startWidth + dx)) + "px"; } if (edge.includes("t")) { const dy = startY - ev.clientY; const maxH = window.innerHeight * 0.9; element.style.maxHeight = Math.max(minHeight, Math.min(maxH, startHeight + dy)) + "px"; } if (edge.includes("b")) { const dy = ev.clientY - startY; const maxH = window.innerHeight * 0.9; element.style.maxHeight = Math.max(minHeight, Math.min(maxH, startHeight + dy)) + "px"; } }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); document.body.style.userSelect = ""; element.style.transition = ""; if (storageKey) { Storage.set(storageKey, JSON.stringify({ width: element.style.width, maxHeight: element.style.maxHeight, })); } }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }); }); // 恢复已保存的尺寸 if (storageKey) { const saved = Storage.get(storageKey, null); if (saved) { try { const size = JSON.parse(saved); if (size.width) element.style.width = size.width; if (size.maxHeight) element.style.maxHeight = size.maxHeight; } catch (e) {} } } }, }; // =========================================== // Notion 站点 UI 模块 // =========================================== const NotionSiteUI = { panel: null, floatBtn: null, isMinimized: true, // 注入样式 injectStyles: () => { DesignSystem.ensureBase(); DesignSystem.ensureChat(); StyleManager.injectOnce(DesignSystem.STYLE_IDS.NOTION, ` /* LDB_UI_NOTION */ .ldb-notion-float-btn { position: fixed; right: 24px; bottom: 24px; width: 52px; height: 52px; border-radius: 999px; border: 1px solid rgba(37, 99, 235, 0.35); background: linear-gradient(135deg, var(--ldb-ui-accent) 0%, var(--ldb-ui-accent-2) 100%); color: #fff; font-size: 22px; cursor: pointer; box-shadow: var(--ldb-ui-shadow-sm); z-index: 2147483647; display: flex; align-items: center; justify-content: center; user-select: none; transition: transform 0.18s ease, box-shadow 0.18s ease; } .ldb-notion-float-btn:hover { transform: translateY(-1px) scale(1.03); box-shadow: var(--ldb-ui-shadow); } .ldb-notion-float-btn.dragging { transform: none; opacity: 0.85; cursor: grabbing; } .ldb-notion-panel { position: fixed; right: 24px; bottom: 96px; width: 380px; max-height: 70vh; z-index: 2147483646; overflow: hidden; display: none; } .ldb-notion-panel.visible { display: block; } .ldb-notion-header-btns { display: flex; gap: 8px; } .ldb-notion-body { max-height: calc(70vh - 56px); overflow-y: auto; } .ldb-notion-toggle-section { display: flex; justify-content: space-between; align-items: center; cursor: pointer; user-select: none; margin-top: 10px; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.10); color: var(--ldb-ui-text); } .ldb-notion-toggle-content.collapsed { display: none; } #ldb-notion-status-container { margin-top: 12px; } `); }, // 创建浮动按钮(可拖拽) createFloatButton: () => { const btn = document.createElement("button"); btn.className = "ldb-notion-float-btn"; btn.setAttribute("data-ldb-root", ""); btn.innerHTML = "🤖"; btn.title = "AI 助手"; // 拖拽状态 let isDragging = false; let hasMoved = false; let offsetX, offsetY; btn.addEventListener("mousedown", (e) => { isDragging = true; hasMoved = false; offsetX = e.clientX - btn.getBoundingClientRect().left; offsetY = e.clientY - btn.getBoundingClientRect().top; btn.classList.add("dragging"); document.body.style.userSelect = "none"; e.preventDefault(); }); document.addEventListener("mousemove", (e) => { if (!isDragging) return; hasMoved = true; const x = Math.max(0, Math.min(window.innerWidth - btn.offsetWidth, e.clientX - offsetX)); const y = Math.max(0, Math.min(window.innerHeight - btn.offsetHeight, e.clientY - offsetY)); btn.style.left = x + "px"; btn.style.top = y + "px"; btn.style.right = "auto"; btn.style.bottom = "auto"; }); document.addEventListener("mouseup", () => { if (!isDragging) return; isDragging = false; btn.classList.remove("dragging"); document.body.style.userSelect = ""; if (hasMoved) { // 保存位置 const rect = btn.getBoundingClientRect(); const right = window.innerWidth - rect.right; const bottom = window.innerHeight - rect.bottom; Storage.set(CONFIG.STORAGE_KEYS.FLOAT_BTN_POSITION, JSON.stringify({ right: right + "px", bottom: bottom + "px" })); } }); btn.addEventListener("click", (e) => { if (hasMoved) { // 拖拽结束,不触发点击 e.preventDefault(); e.stopPropagation(); return; } NotionSiteUI.togglePanel(); }); // 恢复保存的位置 const savedPosition = Storage.get(CONFIG.STORAGE_KEYS.FLOAT_BTN_POSITION, null); if (savedPosition) { try { const pos = JSON.parse(savedPosition); btn.style.right = pos.right || "24px"; btn.style.bottom = pos.bottom || "24px"; } catch (e) {} } document.body.appendChild(btn); NotionSiteUI.floatBtn = btn; return btn; }, // 创建面板 createPanel: () => { const panel = document.createElement("div"); panel.className = "ldb-notion-panel"; panel.setAttribute("data-ldb-root", ""); panel.innerHTML = ` 🤖 AI 助手 🌙 × 🤖 你好!我是 ${Utils.escapeHtml(Storage.get(CONFIG.STORAGE_KEYS.AGENT_PERSONA_NAME, CONFIG.DEFAULTS.agentPersonaName))} 试试下面的快捷命令 💡 帮助 🔍 搜索 📂 自动分类 📝 总结 🐙 GitHub 📖 书签 发送 🗑️ 清空 ⚙️ 设置 ▶ Notion API Key 数据库 / 页面 未选择 所有工作区数据库 🔄 AI 服务 OpenAI Claude Gemini 模型 🔄 获取 AI API Key 自定义端点 (可选) 分类列表 刷新页数上限 5 页 (500 条) 10 页 (1000 条) 20 页 (2000 条) 50 页 (5000 条) 无限制 刷新工作区列表时每类的最大分页数 🤖 Agent 个性化 助手名字 语气风格 友好 专业 幽默 简洁 热情 专业领域 自定义指令 (可选) 🐙 GitHub 收藏导入 GitHub 用户名 GitHub Token (可选,提高速率限制) 不填写也可使用,但有 60 次/小时限制 导入类型 ⭐ Stars 📦 Repos 🍴 Forks 📝 Gists 📖 浏览器书签导入 💾 保存设置 `; document.body.appendChild(panel); NotionSiteUI.panel = panel; // 阻止面板内的键盘和剪贴板事件冒泡到 Notion const stopPropagation = (e) => e.stopPropagation(); panel.addEventListener("copy", stopPropagation); panel.addEventListener("paste", stopPropagation); panel.addEventListener("cut", stopPropagation); panel.addEventListener("keydown", stopPropagation); panel.addEventListener("keyup", stopPropagation); panel.addEventListener("keypress", stopPropagation); return panel; }, // 切换面板显示 togglePanel: () => { if (!NotionSiteUI.panel) return; NotionSiteUI.isMinimized = !NotionSiteUI.isMinimized; if (NotionSiteUI.isMinimized) { NotionSiteUI.panel.classList.remove("visible"); } else { NotionSiteUI.panel.classList.add("visible"); } Storage.set(CONFIG.STORAGE_KEYS.NOTION_PANEL_MINIMIZED, NotionSiteUI.isMinimized); }, // 绑定事件 bindEvents: () => { const panel = NotionSiteUI.panel; // 快捷命令 chips panel.querySelectorAll(".ldb-chat-chip").forEach(chip => { chip.onclick = () => { const cmd = chip.getAttribute("data-cmd"); const input = panel.querySelector("#ldb-chat-input"); if (input && cmd) { input.value = cmd; ChatUI.sendMessage(); } }; }); // 关闭按钮 panel.querySelector("#ldb-notion-close").onclick = () => { NotionSiteUI.togglePanel(); }; // 主题切换 panel.querySelector("#ldb-notion-theme-toggle").onclick = () => { DesignSystem.toggleTheme(); }; // 设置折叠 panel.querySelector("#ldb-notion-settings-toggle").onclick = () => { const content = panel.querySelector("#ldb-notion-settings-content"); const arrow = panel.querySelector("#ldb-notion-settings-arrow"); content.classList.toggle("collapsed"); arrow.textContent = content.classList.contains("collapsed") ? "▶" : "▼"; }; // 保存设置 panel.querySelector("#ldb-notion-save-settings").onclick = () => { Storage.set(CONFIG.STORAGE_KEYS.NOTION_API_KEY, panel.querySelector("#ldb-notion-api-key").value.trim()); const targetDbValue = panel.querySelector("#ldb-notion-ai-target-db").value; Storage.set(CONFIG.STORAGE_KEYS.AI_TARGET_DB, targetDbValue); if (targetDbValue && targetDbValue !== "__all__" && !targetDbValue.startsWith("page:")) { Storage.set(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, targetDbValue); } Storage.set(CONFIG.STORAGE_KEYS.AI_SERVICE, panel.querySelector("#ldb-notion-ai-service").value); Storage.set(CONFIG.STORAGE_KEYS.AI_MODEL, panel.querySelector("#ldb-notion-ai-model").value); Storage.set(CONFIG.STORAGE_KEYS.AI_API_KEY, panel.querySelector("#ldb-notion-ai-api-key").value.trim()); Storage.set(CONFIG.STORAGE_KEYS.AI_BASE_URL, panel.querySelector("#ldb-notion-ai-base-url").value.trim()); Storage.set(CONFIG.STORAGE_KEYS.AI_CATEGORIES, panel.querySelector("#ldb-notion-ai-categories").value.trim()); Storage.set(CONFIG.STORAGE_KEYS.WORKSPACE_MAX_PAGES, parseInt(panel.querySelector("#ldb-notion-workspace-max-pages").value) || 0); Storage.set(CONFIG.STORAGE_KEYS.AGENT_PERSONA_NAME, panel.querySelector("#ldb-notion-persona-name").value.trim() || CONFIG.DEFAULTS.agentPersonaName); Storage.set(CONFIG.STORAGE_KEYS.AGENT_PERSONA_TONE, panel.querySelector("#ldb-notion-persona-tone").value); Storage.set(CONFIG.STORAGE_KEYS.AGENT_PERSONA_EXPERTISE, panel.querySelector("#ldb-notion-persona-expertise").value.trim() || CONFIG.DEFAULTS.agentPersonaExpertise); Storage.set(CONFIG.STORAGE_KEYS.AGENT_PERSONA_INSTRUCTIONS, panel.querySelector("#ldb-notion-persona-instructions").value.trim()); Storage.set(CONFIG.STORAGE_KEYS.GITHUB_USERNAME, panel.querySelector("#ldb-notion-github-username").value.trim()); Storage.set(CONFIG.STORAGE_KEYS.GITHUB_TOKEN, panel.querySelector("#ldb-notion-github-token").value.trim()); // 保存 GitHub 导入类型 const githubTypes = [...panel.querySelectorAll(".ldb-notion-github-type:checked")].map(cb => cb.value); GitHubAPI.setImportTypes(githubTypes.length > 0 ? githubTypes : ["stars"]); NotionSiteUI.showStatus("设置已保存", "success"); }; // 刷新数据库列表(合并后的唯一刷新按钮) panel.querySelector("#ldb-notion-refresh-workspace").onclick = async () => { const apiKey = panel.querySelector("#ldb-notion-api-key").value.trim(); const refreshBtn = panel.querySelector("#ldb-notion-refresh-workspace"); const workspaceTip = panel.querySelector("#ldb-notion-workspace-tip"); if (!apiKey) { NotionSiteUI.showStatus("请先填写 Notion API Key", "error"); return; } refreshBtn.disabled = true; refreshBtn.innerHTML = "⏳"; workspaceTip.style.color = ""; workspaceTip.textContent = "正在获取数据库列表..."; try { const workspace = await WorkspaceService.fetchWorkspaceStaged(apiKey, { includePages: true, onProgress: (progress) => { if (progress.phase === "databases") { workspaceTip.textContent = `正在获取数据库列表... 已加载 ${progress.loaded} 个`; } else if (progress.phase === "pages") { workspaceTip.textContent = `数据库已就绪,正在获取页面... 已加载 ${progress.loaded} 个`; } }, onPhaseComplete: (phase, partialWorkspace) => { const workspaceData = { apiKeyHash: apiKey.slice(-8), databases: partialWorkspace.databases || [], pages: partialWorkspace.pages || [], timestamp: Date.now(), }; Storage.set(CONFIG.STORAGE_KEYS.WORKSPACE_PAGES, JSON.stringify(workspaceData)); NotionSiteUI.updateAITargetDbOptions(workspaceData.databases, workspaceData.pages); if (phase === "databases") { workspaceTip.textContent = `✅ 已加载 ${workspaceData.databases.length} 个数据库,可先选择目标;页面列表继续加载中...`; workspaceTip.style.color = "#34d399"; } }, }); const workspaceData = { apiKeyHash: apiKey.slice(-8), databases: workspace.databases, pages: workspace.pages, timestamp: Date.now(), }; Storage.set(CONFIG.STORAGE_KEYS.WORKSPACE_PAGES, JSON.stringify(workspaceData)); NotionSiteUI.updateAITargetDbOptions(workspace.databases, workspace.pages); workspaceTip.textContent = `✅ 获取到 ${workspace.databases.length} 个数据库,${workspace.pages.length} 个页面`; workspaceTip.style.color = "#34d399"; } catch (error) { workspaceTip.textContent = `❌ ${error.message}`; workspaceTip.style.color = "#f87171"; } finally { refreshBtn.disabled = false; refreshBtn.innerHTML = "🔄"; } }; // 数据库/页面下拉框选择变更 panel.querySelector("#ldb-notion-ai-target-db").onchange = (e) => { const value = e.target.value; if (value && value !== "__all__") { Storage.set(CONFIG.STORAGE_KEYS.AI_TARGET_DB, value); // 选中数据库 → 同时保存 NOTION_DATABASE_ID;选中页面 → 不覆盖 if (!value.startsWith("page:")) { Storage.set(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, value); } } else if (value === "__all__") { Storage.set(CONFIG.STORAGE_KEYS.AI_TARGET_DB, "__all__"); } else { Storage.set(CONFIG.STORAGE_KEYS.AI_TARGET_DB, ""); } }; // AI 服务切换 - 更新模型列表并保存(优先使用缓存) panel.querySelector("#ldb-notion-ai-service").onchange = (e) => { const newService = e.target.value; Storage.set(CONFIG.STORAGE_KEYS.AI_SERVICE, newService); // 优先使用缓存的模型列表 const cachedModels = Storage.get(CONFIG.STORAGE_KEYS.FETCHED_MODELS, "{}"); try { const modelsData = JSON.parse(cachedModels); if (modelsData[newService]?.models?.length > 0) { NotionSiteUI.updateAIModelOptions(newService, modelsData[newService].models); } else { NotionSiteUI.updateAIModelOptions(newService); } } catch { NotionSiteUI.updateAIModelOptions(newService); } // 重置模型为新服务的默认模型 const provider = AIService.PROVIDERS[newService]; if (provider?.defaultModel) { Storage.set(CONFIG.STORAGE_KEYS.AI_MODEL, provider.defaultModel); } }; // AI 模型切换 - 保存选择 panel.querySelector("#ldb-notion-ai-model").onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.AI_MODEL, e.target.value); }; // 获取模型列表 panel.querySelector("#ldb-notion-ai-fetch-models").onclick = async () => { const aiApiKey = panel.querySelector("#ldb-notion-ai-api-key").value.trim(); const aiService = panel.querySelector("#ldb-notion-ai-service").value; const aiBaseUrl = panel.querySelector("#ldb-notion-ai-base-url").value.trim(); const fetchBtn = panel.querySelector("#ldb-notion-ai-fetch-models"); const modelTip = panel.querySelector("#ldb-notion-ai-model-tip"); if (!aiApiKey) { NotionSiteUI.showStatus("请先填写 AI API Key", "error"); return; } fetchBtn.disabled = true; fetchBtn.innerHTML = "⏳ 获取中..."; modelTip.textContent = ""; try { const models = await AIService.fetchModels(aiService, aiApiKey, aiBaseUrl); NotionSiteUI.updateAIModelOptions(aiService, models, true); // 持久化保存获取的模型列表 const cachedModels = Storage.get(CONFIG.STORAGE_KEYS.FETCHED_MODELS, "{}"); const modelsData = JSON.parse(cachedModels); modelsData[aiService] = { models, timestamp: Date.now() }; Storage.set(CONFIG.STORAGE_KEYS.FETCHED_MODELS, JSON.stringify(modelsData)); modelTip.textContent = `✅ 获取到 ${models.length} 个可用模型`; modelTip.style.color = "#34d399"; } catch (error) { modelTip.textContent = `❌ ${error.message}`; modelTip.style.color = "#f87171"; } finally { fetchBtn.disabled = false; fetchBtn.innerHTML = "🔄 获取"; } }; // 拖拽面板 NotionSiteUI.makeDraggable(panel, panel.querySelector(".ldb-notion-header")); }, // 加载配置 loadConfig: () => { const panel = NotionSiteUI.panel; panel.querySelector("#ldb-notion-api-key").value = Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, ""); panel.querySelector("#ldb-notion-ai-service").value = Storage.get(CONFIG.STORAGE_KEYS.AI_SERVICE, CONFIG.DEFAULTS.aiService); panel.querySelector("#ldb-notion-ai-api-key").value = Storage.get(CONFIG.STORAGE_KEYS.AI_API_KEY, ""); panel.querySelector("#ldb-notion-ai-base-url").value = Storage.get(CONFIG.STORAGE_KEYS.AI_BASE_URL, ""); panel.querySelector("#ldb-notion-ai-categories").value = Storage.get(CONFIG.STORAGE_KEYS.AI_CATEGORIES, CONFIG.DEFAULTS.aiCategories); panel.querySelector("#ldb-notion-workspace-max-pages").value = Storage.get(CONFIG.STORAGE_KEYS.WORKSPACE_MAX_PAGES, CONFIG.DEFAULTS.workspaceMaxPages); // 加载 Agent 个性化设置 panel.querySelector("#ldb-notion-persona-name").value = Storage.get(CONFIG.STORAGE_KEYS.AGENT_PERSONA_NAME, CONFIG.DEFAULTS.agentPersonaName); panel.querySelector("#ldb-notion-persona-tone").value = Storage.get(CONFIG.STORAGE_KEYS.AGENT_PERSONA_TONE, CONFIG.DEFAULTS.agentPersonaTone); panel.querySelector("#ldb-notion-persona-expertise").value = Storage.get(CONFIG.STORAGE_KEYS.AGENT_PERSONA_EXPERTISE, CONFIG.DEFAULTS.agentPersonaExpertise); panel.querySelector("#ldb-notion-persona-instructions").value = Storage.get(CONFIG.STORAGE_KEYS.AGENT_PERSONA_INSTRUCTIONS, CONFIG.DEFAULTS.agentPersonaInstructions); // 加载 GitHub 设置 panel.querySelector("#ldb-notion-github-username").value = Storage.get(CONFIG.STORAGE_KEYS.GITHUB_USERNAME, ""); panel.querySelector("#ldb-notion-github-token").value = Storage.get(CONFIG.STORAGE_KEYS.GITHUB_TOKEN, ""); // 加载 GitHub 导入类型 const savedGHTypes = GitHubAPI.getImportTypes(); panel.querySelectorAll(".ldb-notion-github-type").forEach(cb => { cb.checked = savedGHTypes.includes(cb.value); }); // 书签扩展状态 const bmStatus = panel.querySelector("#ldb-notion-bookmark-status"); if (bmStatus) { if (BookmarkBridge.isExtensionAvailable()) { bmStatus.innerHTML = '✅ 扩展已安装 — 在 AI 对话中输入「导入书签」即可'; } else { bmStatus.innerHTML = `❌ 扩展未安装 — ${InstallHelper.renderInstallLink("一键安装浏览器扩展")}`; } } // 加载数据库/页面下拉框(始终调用以确保兼容选项被添加) const cachedWsForDb = Storage.get(CONFIG.STORAGE_KEYS.WORKSPACE_PAGES, "{}"); let cachedDatabases = []; let cachedPages = []; try { const wsData = JSON.parse(cachedWsForDb); cachedDatabases = wsData.databases || []; cachedPages = wsData.pages || []; } catch {} NotionSiteUI.updateAITargetDbOptions(cachedDatabases, cachedPages); // 加载 AI 模型选项(优先使用缓存的模型列表) const aiService = Storage.get(CONFIG.STORAGE_KEYS.AI_SERVICE, CONFIG.DEFAULTS.aiService); const cachedModels = Storage.get(CONFIG.STORAGE_KEYS.FETCHED_MODELS, "{}"); try { const modelsData = JSON.parse(cachedModels); if (modelsData[aiService]?.models?.length > 0) { NotionSiteUI.updateAIModelOptions(aiService, modelsData[aiService].models); } else { NotionSiteUI.updateAIModelOptions(aiService); } } catch { NotionSiteUI.updateAIModelOptions(aiService); } // 设置保存的模型 const savedModel = Storage.get(CONFIG.STORAGE_KEYS.AI_MODEL, ""); if (savedModel) { const modelSelect = panel.querySelector("#ldb-notion-ai-model"); const optionExists = Array.from(modelSelect.options).some(opt => opt.value === savedModel); if (optionExists) { modelSelect.value = savedModel; } } // 恢复面板位置 const savedPosition = Storage.get(CONFIG.STORAGE_KEYS.NOTION_PANEL_POSITION, null); if (savedPosition) { try { const pos = JSON.parse(savedPosition); panel.style.right = pos.right || "24px"; panel.style.bottom = pos.bottom || "96px"; } catch (e) {} } }, // 更新数据库/页面下拉框 updateAITargetDbOptions: (databases, pages = []) => { const select = NotionSiteUI.panel.querySelector("#ldb-notion-ai-target-db"); if (!select) return; const savedValue = Storage.get(CONFIG.STORAGE_KEYS.AI_TARGET_DB, ""); const savedDbId = Storage.get(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, ""); let options = '未选择'; options += '所有工作区数据库'; const knownIds = new Set(); if (databases.length > 0) { options += ''; databases.forEach(db => { knownIds.add(db.id); options += `📁 ${Utils.escapeHtml(db.title)}`; }); options += ''; } // 只显示工作区顶级页面(value 带 page: 前缀以区分类型) const workspacePages = pages.filter(p => p.parent === "workspace"); if (workspacePages.length > 0) { options += ''; workspacePages.forEach(page => { const val = `page:${page.id}`; knownIds.add(val); options += `📄 ${Utils.escapeHtml(page.title)}`; }); options += ''; } // 如果已保存的值不在列表中,添加一个兼容选项 const activeId = savedValue || savedDbId; if (activeId && activeId !== "__all__" && !knownIds.has(activeId)) { options += `已配置 (ID: ${activeId.slice(0, 8)}...)`; } select.innerHTML = options; // 恢复选中值:优先 AI_TARGET_DB,其次兼容 NOTION_DATABASE_ID const restoreId = savedValue || savedDbId; if (restoreId) { select.value = restoreId; } }, // 更新 AI 模型选项 updateAIModelOptions: (service, customModels = null, preserveSelection = false) => { const modelSelect = NotionSiteUI.panel.querySelector("#ldb-notion-ai-model"); const provider = AIService.PROVIDERS[service]; if (!provider || !modelSelect) return; const models = customModels || provider.models; const defaultModel = provider.defaultModel; // 保留当前选择的模型(如果需要且存在于新列表中) const currentValue = modelSelect.value; const shouldPreserve = preserveSelection && currentValue && models.includes(currentValue); modelSelect.innerHTML = models.map(model => { const isSelected = shouldPreserve ? model === currentValue : model === defaultModel; return `${model}`; }).join(""); }, // 显示状态 showStatus: (message, type = "info") => { const container = NotionSiteUI.panel.querySelector("#ldb-notion-status-container"); container.innerHTML = ` ${message} × `; // 添加关闭按钮事件 const closeBtn = container.querySelector(".ldb-status-close"); if (closeBtn) { closeBtn.onclick = () => { container.innerHTML = ""; }; } // 错误消息延长显示时间(10秒),其他类型3秒 const timeout = type === "error" ? 10000 : 3000; setTimeout(() => { container.innerHTML = ""; }, timeout); }, // 拖拽功能 makeDraggable: (element, handle) => { let offsetX, offsetY, isDragging = false; handle.onmousedown = (e) => { if (e.target.tagName === "BUTTON") return; isDragging = true; offsetX = e.clientX - element.offsetLeft; offsetY = e.clientY - element.offsetTop; document.body.style.userSelect = "none"; }; document.onmousemove = (e) => { if (!isDragging) return; const x = Math.max(0, Math.min(window.innerWidth - element.offsetWidth, e.clientX - offsetX)); const y = Math.max(0, Math.min(window.innerHeight - element.offsetHeight, e.clientY - offsetY)); element.style.left = x + "px"; element.style.top = y + "px"; element.style.right = "auto"; element.style.bottom = "auto"; }; document.onmouseup = () => { if (isDragging) { // 保存位置(使用 right 和 bottom) const rect = element.getBoundingClientRect(); const right = window.innerWidth - rect.right; const bottom = window.innerHeight - rect.bottom; Storage.set(CONFIG.STORAGE_KEYS.NOTION_PANEL_POSITION, JSON.stringify({ right: right + "px", bottom: bottom + "px" })); } isDragging = false; document.body.style.userSelect = ""; }; }, // 初始化 AI 助手模块(复用 AIAssistant) initAIAssistant: () => { // 重写 getSettings 以适配 Notion 站点 UI const originalGetSettings = AIAssistant.getSettings; AIAssistant.getSettings = () => { // 优先使用 Notion 站点 UI 的输入框(如果存在) const notionPanel = NotionSiteUI.panel; if (notionPanel) { const aiService = notionPanel.querySelector("#ldb-notion-ai-service")?.value || Storage.get(CONFIG.STORAGE_KEYS.AI_SERVICE, CONFIG.DEFAULTS.aiService); const selectedModel = notionPanel.querySelector("#ldb-notion-ai-model")?.value || Storage.get(CONFIG.STORAGE_KEYS.AI_MODEL, ""); // 如果没有选择模型,使用默认模型 const provider = AIService.PROVIDERS[aiService]; const aiModel = selectedModel || provider?.defaultModel || ""; return { notionApiKey: notionPanel.querySelector("#ldb-notion-api-key")?.value.trim() || Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, ""), notionDatabaseId: (() => { const targetDb = notionPanel.querySelector("#ldb-notion-ai-target-db")?.value || ""; if (targetDb && targetDb !== "__all__" && !targetDb.startsWith("page:")) return targetDb; return Storage.get(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, ""); })(), aiApiKey: notionPanel.querySelector("#ldb-notion-ai-api-key")?.value.trim() || Storage.get(CONFIG.STORAGE_KEYS.AI_API_KEY, ""), aiService: aiService, aiModel: aiModel, aiBaseUrl: notionPanel.querySelector("#ldb-notion-ai-base-url")?.value.trim() || Storage.get(CONFIG.STORAGE_KEYS.AI_BASE_URL, ""), categories: Utils.parseAICategories( notionPanel.querySelector("#ldb-notion-ai-categories")?.value.trim() || Storage.get(CONFIG.STORAGE_KEYS.AI_CATEGORIES, CONFIG.DEFAULTS.aiCategories) ), }; } return originalGetSettings(); }; }, // 初始化 init: () => { NotionSiteUI.injectStyles(); NotionSiteUI.createFloatButton(); NotionSiteUI.createPanel(); NotionSiteUI.bindEvents(); NotionSiteUI.loadConfig(); NotionSiteUI.initAIAssistant(); // 面板可拉伸(左边+上边+左上角) PanelResize.makeResizable(NotionSiteUI.panel, { edges: ["l", "t", "tl"], storageKey: CONFIG.STORAGE_KEYS.PANEL_SIZE_NOTION, minWidth: 300, minHeight: 250, }); // 初始化对话 UI ChatState.load(); ChatUI.renderMessages(); ChatUI.bindEvents(); // 检查是否需要展开 if (!Storage.get(CONFIG.STORAGE_KEYS.NOTION_PANEL_MINIMIZED, true)) { NotionSiteUI.isMinimized = false; NotionSiteUI.panel.classList.add("visible"); } }, }; // =========================================== // UI 组件 // =========================================== const UI = { panel: null, miniBtn: null, isMinimized: false, bookmarks: [], selectedBookmarks: new Set(), selectedUnexportedCount: 0, totalUnexportedCount: 0, bookmarkListBound: false, refs: null, // 缓存高频节点引用 cacheRefs: () => { const panel = UI.panel; if (!panel) { UI.refs = null; return; } UI.refs = { statusContainer: panel.querySelector("#ldb-status-container"), bookmarkList: panel.querySelector("#ldb-bookmark-list"), selectCount: panel.querySelector("#ldb-select-count"), selectAll: panel.querySelector("#ldb-select-all"), bookmarkCount: panel.querySelector("#ldb-bookmark-count"), bookmarksLabel: panel.querySelector("#ldb-bookmarks-label"), autoImportLabel: panel.querySelector("#ldb-auto-import-label"), autoImportIntervalLabel: panel.querySelector("#ldb-auto-import-interval-label"), exportBtn: panel.querySelector("#ldb-export"), bookmarkListContainer: panel.querySelector("#ldb-bookmark-list-container"), reportContainer: panel.querySelector("#ldb-report-container"), autoImportStatus: panel.querySelector("#ldb-auto-import-status"), sourcePartitionsToggle: panel.querySelector("#ldb-source-partitions-toggle"), sourcePartitionsContent: panel.querySelector("#ldb-source-partitions-content"), sourcePartitionsArrow: panel.querySelector("#ldb-source-partitions-arrow"), sourceSelectLinuxdo: panel.querySelector("#ldb-source-select-linuxdo"), sourceSelectGithub: panel.querySelector("#ldb-source-select-github"), updateCheckBtn: panel.querySelector("#ldb-update-check-btn"), updateAutoEnabled: panel.querySelector("#ldb-update-auto-enabled"), updateAutoOptions: panel.querySelector("#ldb-update-auto-options"), updateIntervalHours: panel.querySelector("#ldb-update-interval-hours"), updateCheckStatus: panel.querySelector("#ldb-update-check-status"), minimizeBtn: panel.querySelector("#ldb-minimize"), closeBtn: panel.querySelector("#ldb-close"), themeToggleBtn: panel.querySelector("#ldb-theme-toggle"), runtimeBadge: panel.querySelector("#ldb-runtime-badge"), tabs: panel.querySelectorAll(".ldb-tab"), tabContents: panel.querySelectorAll(".ldb-tab-content"), filterToggle: panel.querySelector("#ldb-filter-toggle"), filterContent: panel.querySelector("#ldb-filter-content"), filterArrow: panel.querySelector("#ldb-filter-arrow"), aiSettingsToggle: panel.querySelector("#ldb-ai-settings-toggle"), aiSettingsContent: panel.querySelector("#ldb-ai-settings-content"), aiSettingsArrow: panel.querySelector("#ldb-ai-settings-arrow"), githubSettingsToggle: panel.querySelector("#ldb-github-settings-toggle"), githubSettingsContent: panel.querySelector("#ldb-github-settings-content"), githubSettingsArrow: panel.querySelector("#ldb-github-settings-arrow"), openGithubSettingsBtn: panel.querySelector("#ldb-open-github-settings"), sourceSettingsToggle: panel.querySelector("#ldb-source-settings-toggle"), sourceSettingsContent: panel.querySelector("#ldb-source-settings-content"), sourceSettingsArrow: panel.querySelector("#ldb-source-settings-arrow"), apiKeyInput: panel.querySelector("#ldb-api-key"), databaseIdInput: panel.querySelector("#ldb-database-id"), parentPageIdInput: panel.querySelector("#ldb-parent-page-id"), exportTargetPageRadio: panel.querySelector("#ldb-export-target-page"), exportTargetDatabaseRadio: panel.querySelector("#ldb-export-target-database"), parentPageGroup: panel.querySelector("#ldb-parent-page-group"), manualDbWrap: panel.querySelector("#ldb-manual-db-wrap"), exportTargetTip: panel.querySelector("#ldb-export-target-tip"), configStatus: panel.querySelector("#ldb-config-status"), loadBookmarksBtn: panel.querySelector("#ldb-load-bookmarks"), importBrowserBookmarksBtn: panel.querySelector("#ldb-import-browser-bookmarks"), exportBtns: panel.querySelector("#ldb-export-btns"), controlBtns: panel.querySelector("#ldb-control-btns"), pauseBtn: panel.querySelector("#ldb-pause"), autoImportEnabled: panel.querySelector("#ldb-auto-import-enabled"), autoImportOptions: panel.querySelector("#ldb-auto-import-options"), autoImportInterval: panel.querySelector("#ldb-auto-import-interval"), linuxdoDedupModeSelect: panel.querySelector("#ldb-linuxdo-dedup-mode"), bookmarkDedupModeSelect: panel.querySelector("#ldb-bookmark-dedup-mode"), aiCategoryAutoDedupCheckbox: panel.querySelector("#ldb-ai-category-auto-dedup"), aiServiceSelect: panel.querySelector("#ldb-ai-service"), aiModelSelect: panel.querySelector("#ldb-ai-model"), aiApiKeyInput: panel.querySelector("#ldb-ai-api-key"), aiBaseUrlInput: panel.querySelector("#ldb-ai-base-url"), aiCategoriesInput: panel.querySelector("#ldb-ai-categories"), workspaceMaxPagesSelect: panel.querySelector("#ldb-workspace-max-pages"), aiTargetDbSelect: panel.querySelector("#ldb-ai-target-db"), permissionLevelSelect: panel.querySelector("#ldb-permission-level"), requireConfirmCheckbox: panel.querySelector("#ldb-require-confirm"), enableAuditLogCheckbox: panel.querySelector("#ldb-enable-audit-log"), logPanel: panel.querySelector("#ldb-log-panel"), workspaceSelect: panel.querySelector("#ldb-workspace-select"), bookmarkExtStatus: panel.querySelector("#ldb-bookmark-ext-status"), selfCheckBtn: panel.querySelector("#ldb-self-check-btn"), copyDiagBtn: panel.querySelector("#ldb-copy-diagnostics-btn"), selfCheckResult: panel.querySelector("#ldb-self-check-result"), onlyFirstCheckbox: panel.querySelector("#ldb-only-first"), onlyOpCheckbox: panel.querySelector("#ldb-only-op"), rangeStartInput: panel.querySelector("#ldb-range-start"), rangeEndInput: panel.querySelector("#ldb-range-end"), imgModeSelect: panel.querySelector("#ldb-img-mode"), requestDelaySelect: panel.querySelector("#ldb-request-delay"), exportConcurrencySelect: panel.querySelector("#ldb-export-concurrency"), validateConfigBtn: panel.querySelector("#ldb-validate-config"), setupDatabaseBtn: panel.querySelector("#ldb-setup-database"), cancelBtn: panel.querySelector("#ldb-cancel"), agentPersonaNameInput: panel.querySelector("#ldb-agent-persona-name"), agentPersonaToneSelect: panel.querySelector("#ldb-agent-persona-tone"), agentPersonaExpertiseInput: panel.querySelector("#ldb-agent-persona-expertise"), agentPersonaInstructionsInput: panel.querySelector("#ldb-agent-persona-instructions"), githubUsernameInput: panel.querySelector("#ldb-github-username"), githubTokenInput: panel.querySelector("#ldb-github-token"), githubTypeCheckboxes: panel.querySelectorAll(".ldb-github-type"), toggleManualDbBtn: panel.querySelector("#ldb-toggle-manual-db"), refreshWorkspaceBtn: panel.querySelector("#ldb-refresh-workspace"), workspaceTip: panel.querySelector("#ldb-workspace-tip"), logToggleBtn: panel.querySelector("#ldb-log-toggle"), logContent: panel.querySelector("#ldb-log-content"), logArrow: panel.querySelector("#ldb-log-arrow"), logClearBtn: panel.querySelector("#ldb-log-clear"), aiRefreshDbsBtn: panel.querySelector("#ldb-ai-refresh-dbs"), aiFetchModelsBtn: panel.querySelector("#ldb-ai-fetch-models"), aiModelTip: panel.querySelector("#ldb-ai-model-tip"), aiTestBtn: panel.querySelector("#ldb-ai-test"), aiTestStatus: panel.querySelector("#ldb-ai-test-status"), }; }, // 样式 injectStyles: () => { DesignSystem.ensureBase(); DesignSystem.ensureChat(); StyleManager.injectOnce(DesignSystem.STYLE_IDS.LINUX_DO, ` /* LDB_UI_LINUX_DO */ .ldb-panel { position: fixed; top: 80px; right: 20px; width: 380px; max-height: 90vh; z-index: 2147483640; display: flex; flex-direction: column; overflow: hidden; } .ldb-panel.minimized { width: auto; max-height: none; overflow: visible; } .ldb-header { cursor: move; border-top-left-radius: var(--ldb-ui-radius); border-top-right-radius: var(--ldb-ui-radius); } .ldb-header-btns { display: flex; gap: 8px; } .ldb-runtime-badge { margin-left: 8px; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; line-height: 1.8; border: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.12); color: var(--ldb-ui-muted); vertical-align: middle; } .ldb-runtime-badge.mode-userscript { color: #0f766e; border-color: rgba(13, 148, 136, 0.35); background: rgba(20, 184, 166, 0.14); } .ldb-runtime-badge.mode-extension { color: #1d4ed8; border-color: rgba(37, 99, 235, 0.35); background: rgba(59, 130, 246, 0.14); } .ldb-body { overflow-y: auto; padding: 14px; } .ldb-body::-webkit-scrollbar { width: 8px; } .ldb-body::-webkit-scrollbar-track { background: transparent; } .ldb-body::-webkit-scrollbar-thumb { background: rgba(148, 163, 184, 0.25); border-radius: 999px; } .ldb-mini-btn { position: fixed; right: 20px; bottom: 80px; width: 52px; height: 52px; border-radius: 999px; border: 1px solid rgba(37, 99, 235, 0.35); background: linear-gradient(135deg, var(--ldb-ui-accent) 0%, var(--ldb-ui-accent-2) 100%); color: #fff; font-size: 22px; cursor: pointer; box-shadow: var(--ldb-ui-shadow-sm); z-index: 2147483641; display: none; align-items: center; justify-content: center; user-select: none; transition: transform 0.18s ease, box-shadow 0.18s ease; } .ldb-mini-btn:hover { transform: translateY(-1px) scale(1.03); box-shadow: var(--ldb-ui-shadow); } .ldb-section { padding: 10px 0; } .ldb-btn-group { display: flex; flex-wrap: wrap; gap: 10px; } .ldb-btn-primary { /* alias for .ldb-btn */ } .ldb-btn-small { padding: 6px 10px; border-radius: 10px; font-size: 12px; } .ldb-link { color: var(--ldb-ui-accent); } .ldb-checkbox-group { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } .ldb-checkbox-item { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ldb-ui-text); user-select: none; } .ldb-checkbox-item input[type="checkbox"], .ldb-checkbox-item input[type="radio"] { accent-color: var(--ldb-ui-accent); } .ldb-toggle-section { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--ldb-ui-border); border-radius: 12px; background: rgba(148, 163, 184, 0.08); } .ldb-source-option-group { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; } .ldb-source-option { border: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.12); color: var(--ldb-ui-text); font-size: 12px; font-weight: 600; border-radius: 10px; padding: 8px 10px; cursor: pointer; transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease; text-align: center; font-family: inherit; } .ldb-source-option:hover { border-color: rgba(37, 99, 235, 0.45); background: rgba(37, 99, 235, 0.14); } .ldb-source-option.active { border-color: var(--ldb-ui-accent); background: rgba(37, 99, 235, 0.18); color: var(--ldb-ui-accent); } .ldb-toggle-switch { position: relative; display: inline-block; width: 44px; height: 24px; } .ldb-toggle-switch input { opacity: 0; width: 0; height: 0; } .ldb-toggle-slider { position: absolute; cursor: pointer; inset: 0; background: rgba(148, 163, 184, 0.28); border: 1px solid var(--ldb-ui-border); transition: background 0.2s ease, border-color 0.2s ease; border-radius: 999px; } .ldb-toggle-slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; top: 50%; transform: translateY(-50%); background: #fff; transition: transform 0.2s ease; border-radius: 50%; box-shadow: 0 6px 16px rgba(2, 6, 23, 0.18); } .ldb-toggle-switch input:checked + .ldb-toggle-slider { background: rgba(37, 99, 235, 0.45); border-color: rgba(37, 99, 235, 0.35); } .ldb-toggle-switch input:checked + .ldb-toggle-slider:before { transform: translateY(-50%) translateX(20px); } .ldb-toggle-content.collapsed { display: none; } .ldb-progress { padding: 10px 12px; border: 1px solid var(--ldb-ui-border); border-radius: 12px; background: rgba(148, 163, 184, 0.08); } .ldb-progress-bar { height: 10px; background: rgba(148, 163, 184, 0.20); border-radius: 999px; overflow: hidden; } .ldb-progress-fill { height: 100%; background: linear-gradient(90deg, var(--ldb-ui-accent), var(--ldb-ui-accent-2)); border-radius: 999px; } .ldb-progress-text { margin-top: 8px; font-size: 12px; color: var(--ldb-ui-muted); display: flex; justify-content: space-between; gap: 10px; } .ldb-bookmarks-info { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid var(--ldb-ui-border); border-radius: 12px; background: rgba(148, 163, 184, 0.08); } .ldb-bookmarks-count { font-size: 20px; font-weight: 800; letter-spacing: 0.2px; color: var(--ldb-ui-text); } .ldb-bookmarks-label { font-size: 12px; color: var(--ldb-ui-muted); text-align: right; } .ldb-bookmark-list { margin-top: 10px; border: 1px solid var(--ldb-ui-border); border-radius: 12px; overflow: hidden; background: rgba(148, 163, 184, 0.06); max-height: 260px; overflow-y: auto; } .ldb-bookmark-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; border-bottom: 1px solid rgba(148, 163, 184, 0.18); cursor: pointer; } .ldb-bookmark-item:hover { background: rgba(37, 99, 235, 0.08); } .ldb-bookmark-item:last-child { border-bottom: none; } .ldb-bookmark-item input[type="checkbox"] { margin-top: 2px; } .ldb-bookmark-item .title { font-size: 13px; font-weight: 650; line-height: 1.45; color: var(--ldb-ui-text); } .ldb-bookmark-item .status { font-size: 11px; margin-top: 4px; color: var(--ldb-ui-muted); } .ldb-bookmark-item .status.exported { color: var(--ldb-ui-success); } .ldb-bookmark-item .status.pending { color: var(--ldb-ui-warning); } .ldb-permission-panel { border: 1px solid var(--ldb-ui-border); border-radius: 12px; background: rgba(148, 163, 184, 0.08); overflow: hidden; } .ldb-permission-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border-bottom: 1px solid rgba(148, 163, 184, 0.18); } .ldb-permission-row:last-child { border-bottom: none; } .ldb-permission-label { font-size: 12px; color: var(--ldb-ui-muted); } .ldb-permission-select { min-width: 160px; } .ldb-log-panel { border: 1px solid var(--ldb-ui-border); border-radius: 12px; overflow: hidden; background: rgba(148, 163, 184, 0.06); } .ldb-log-header { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; cursor: pointer; user-select: none; background: rgba(148, 163, 184, 0.10); border-bottom: 1px solid rgba(148, 163, 184, 0.18); } .ldb-log-title { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ldb-ui-text); font-weight: 700; } .ldb-log-badge { padding: 1px 8px; border-radius: 999px; border: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.10); font-size: 11px; color: var(--ldb-ui-muted); } .ldb-log-content { padding: 10px 12px; } .ldb-log-content.collapsed { display: none; } .ldb-log-item { display: grid; grid-template-columns: 18px 1fr; gap: 10px; padding: 8px 0; border-bottom: 1px solid rgba(148, 163, 184, 0.14); } .ldb-log-item:last-child { border-bottom: none; } .ldb-log-item .icon { font-size: 14px; line-height: 1.2; opacity: 0.9; } .ldb-log-item .content { font-size: 12px; color: var(--ldb-ui-text); line-height: 1.5; } .ldb-log-item .operation { font-weight: 650; } .ldb-log-item .time, .ldb-log-item .duration { margin-top: 2px; font-size: 11px; color: var(--ldb-ui-muted); } .ldb-log-item .error { margin-top: 4px; color: var(--ldb-ui-danger); font-size: 11px; } .ldb-log-empty { padding: 10px 0; color: var(--ldb-ui-muted); font-size: 12px; text-align: center; } .ldb-log-actions { margin-top: 10px; display: flex; justify-content: flex-end; } .ldb-log-clear-btn { border: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.10); color: var(--ldb-ui-text); border-radius: 10px; padding: 6px 10px; cursor: pointer; font-size: 12px; } .ldb-log-clear-btn:hover { background: rgba(148, 163, 184, 0.16); } .ldb-control-btns { display: flex; gap: 10px; flex-wrap: wrap; } /* Tab 导航 */ .ldb-tabs { display: flex; border-bottom: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.06); padding: 0 4px; } .ldb-tab { flex: 1; padding: 10px 6px; border: none; background: transparent; color: var(--ldb-ui-muted); font-size: 12px; font-weight: 600; cursor: pointer; text-align: center; border-bottom: 2px solid transparent; transition: color 0.2s ease, border-color 0.2s ease; user-select: none; font-family: inherit; white-space: nowrap; } .ldb-tab:hover { color: var(--ldb-ui-text); background: rgba(148, 163, 184, 0.08); } .ldb-tab.active { color: var(--ldb-ui-accent); border-bottom-color: var(--ldb-ui-accent); } .ldb-tab-content { display: none; } .ldb-tab-content.active { display: block; } /* 主题切换按钮 */ .ldb-theme-btn { width: 30px; height: 30px; border-radius: 10px; border: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.12); cursor: pointer; user-select: none; display: inline-flex; align-items: center; justify-content: center; padding: 0; line-height: 1; font-size: 14px; transition: background 0.2s ease; } .ldb-theme-btn:hover { background: rgba(148, 163, 184, 0.22); } /* 响应式 */ @media (max-width: 480px) { .ldb-panel { right: 0 !important; left: 0 !important; top: auto !important; bottom: 0 !important; width: 100% !important; max-height: 70vh; border-radius: var(--ldb-ui-radius) var(--ldb-ui-radius) 0 0; } .ldb-mini-btn { right: 12px; bottom: 12px; } } `); }, // 创建面板 createPanel: () => { const panel = document.createElement("div"); panel.className = "ldb-panel"; panel.setAttribute("data-ldb-root", ""); panel.innerHTML = ` 📚 LD-Notion 检测中... 🌙 − × 📚 收藏 🤖 AI ⚙️ 设置 - 已加载收藏数量 收藏来源分区 ▶ Linux.do 收藏分区 GitHub 收藏分区 来源自动化设置 ▶ 启用自动导入新收藏 轮询间隔 仅页面加载时 每 3 分钟 每 5 分钟 每 10 分钟 每 30 分钟 Linux.do 导入去重 自动去重 允许重复(手动勾选) 书签导入去重 自动去重 允许重复(手动勾选) 分类列表自动去重 检查更新 自动检查更新 检查间隔 每 24 小时 每 72 小时 每 168 小时 🔄 加载收藏列表 📖 导入浏览器书签 全选/取消 已选 0 个 📤 开始导出 ⏸️ 暂停 ⏹️ 取消 🤖 你好!我是 ${Utils.escapeHtml(Storage.get(CONFIG.STORAGE_KEYS.AGENT_PERSONA_NAME, CONFIG.DEFAULTS.agentPersonaName))} 试试输入「帮助」查看我能做什么 💡 帮助 🔍 搜索 📂 分类 📝 总结 🐙 GitHub 📖 书签 发送 🗑️ 清空 Notion 配置 API Key 在 Notion Integrations 创建 数据库 / 页面 -- 从工作区选择 -- 🔄 高级:手动输入数据库 ID 优先从工作区列表选择,无法加载时再手动输入 导出目标 数据库(推荐) 页面(子页面) 导出为数据库条目,支持筛选和排序 父页面 ID 帖子将作为子页面创建在此页面下 验证配置 自动设置数据库 权限级别 只读 标准 高级 管理员 危险操作确认 审计日志 筛选设置 ▶ 仅主楼 仅楼主 楼层范围 至 图片处理 上传到 Notion 外链引用 跳过图片 Notion 免费套餐文件需小于 5MB;付费套餐 PDF 小于 20MB、图片小于 5MB。若图片上传报错,脚本会自动尝试按文件上传。 请求间隔 快速 (200ms) 正常 (500ms) 慢速 (1秒) 较慢 (2秒) 很慢 (3秒) 超慢 (5秒) 极慢 (10秒) 龟速 (30秒) 并发数 串行 (1个) 2 个并发 3 个并发 5 个并发 AI 设置 ▶ AI 服务 OpenAI Claude Gemini 模型 🔄 获取 API Key 自定义端点 (可选) 支持第三方 OpenAI 兼容 API 分类列表 逗号分隔,用于自动分类功能 查询数据库 当前配置的数据库 所有工作区数据库 🔄 AI 查询数据库时的目标范围 刷新页数上限 5 页 (500 条) 10 页 (1000 条) 20 页 (2000 条) 50 页 (5000 条) 无限制 刷新工作区列表时每类的最大分页数 测试连接 🤖 Agent 个性化 助手名字 语气风格 友好 专业 幽默 简洁 热情 专业领域 自定义指令 (可选) Agent 每次对话都会遵循的个性化指令 🐙 GitHub 导入 ▶ 🎯 一键定位 GitHub Token GitHub 用户名 GitHub Token (可选) 不填写也可使用,但有速率限制 导入类型 ⭐ Stars 📦 Repos 🍴 Forks 📝 Gists 📖 浏览器书签 🩺 运行自检 执行自检 复制诊断信息 📋 操作日志 0 ▶ 清除日志 `; document.body.appendChild(panel); UI.panel = panel; UI.cacheRefs(); // 绑定事件 UI.bindEvents(); // 加载保存的配置 UI.loadConfig(); }, // 创建最小化按钮 createMiniButton: () => { const btn = document.createElement("button"); btn.className = "ldb-mini-btn"; btn.setAttribute("data-ldb-root", ""); btn.innerHTML = "📚"; btn.title = "打开收藏导出工具"; btn.style.display = "none"; btn.onclick = () => { UI.panel.style.display = "block"; btn.style.display = "none"; Storage.set(CONFIG.STORAGE_KEYS.PANEL_MINIMIZED, false); }; document.body.appendChild(btn); return btn; }, // 绑定事件 bindEvents: () => { const panel = UI.panel; const refs = UI.refs || {}; const body = panel.querySelector(".ldb-body"); const isUserscriptMode = typeof GM_info !== "undefined" && !!GM_info.scriptHandler; const hasBridgeMarker = BookmarkBridge.isExtensionAvailable(); if (refs.runtimeBadge) { refs.runtimeBadge.textContent = isUserscriptMode ? "Userscript" : "Extension"; refs.runtimeBadge.classList.toggle("mode-userscript", isUserscriptMode); refs.runtimeBadge.classList.toggle("mode-extension", !isUserscriptMode); refs.runtimeBadge.title = isUserscriptMode ? "当前运行模式:Userscript(建议搭配 chrome-extension 书签桥接)" : "当前运行模式:Extension(独立扩展)"; } if (isUserscriptMode && hasBridgeMarker && !Storage.get(CONFIG.STORAGE_KEYS.MODE_CONFLICT_TIP_SHOWN, false)) { Storage.set(CONFIG.STORAGE_KEYS.MODE_CONFLICT_TIP_SHOWN, true); UI.showStatus("检测到桥接扩展已注入。若你也安装了独立版 chrome-extension-full,请关闭其一以避免模式混用。", "info"); } panel.addEventListener("wheel", (e) => { if (!body) return; const target = e.target; if (!(target instanceof HTMLElement)) return; if (target.closest(".ldb-body")) return; if (target.closest("input, textarea, select, [contenteditable=\"true\"]")) return; if (e.deltaY === 0) return; body.scrollTop += e.deltaY; e.preventDefault(); }, { passive: false }); // 最小化 (refs.minimizeBtn || panel.querySelector("#ldb-minimize")).onclick = () => { panel.style.display = "none"; UI.miniBtn.style.display = "flex"; Storage.set(CONFIG.STORAGE_KEYS.PANEL_MINIMIZED, true); }; // 关闭 (refs.closeBtn || panel.querySelector("#ldb-close")).onclick = () => { panel.remove(); UI.miniBtn.remove(); }; // 主题切换 (refs.themeToggleBtn || panel.querySelector("#ldb-theme-toggle")).onclick = () => { DesignSystem.toggleTheme(); }; // Tab 切换 (refs.tabs || panel.querySelectorAll(".ldb-tab")).forEach(tab => { tab.onclick = () => { const tabName = tab.getAttribute("data-tab"); // 更新 tab 按钮状态 (refs.tabs || panel.querySelectorAll(".ldb-tab")).forEach(t => t.classList.remove("active")); tab.classList.add("active"); // 更新 tab 内容显示 (refs.tabContents || panel.querySelectorAll(".ldb-tab-content")).forEach(c => c.classList.remove("active")); const content = panel.querySelector(`[data-tab-content="${tabName}"]`); if (content) content.classList.add("active"); // 持久化 Storage.set(CONFIG.STORAGE_KEYS.ACTIVE_TAB, tabName); }; }); // 恢复上次选择的 tab const savedTab = Storage.get(CONFIG.STORAGE_KEYS.ACTIVE_TAB, "bookmarks"); const tabBtn = panel.querySelector(`.ldb-tab[data-tab="${savedTab}"]`); if (tabBtn) tabBtn.click(); // 折叠筛选设置 (refs.filterToggle || panel.querySelector("#ldb-filter-toggle")).onclick = () => { const content = refs.filterContent || panel.querySelector("#ldb-filter-content"); const arrow = refs.filterArrow || panel.querySelector("#ldb-filter-arrow"); content.classList.toggle("collapsed"); arrow.textContent = content.classList.contains("collapsed") ? "▶" : "▼"; }; // 折叠 AI 设置 (refs.aiSettingsToggle || panel.querySelector("#ldb-ai-settings-toggle")).onclick = () => { const content = refs.aiSettingsContent || panel.querySelector("#ldb-ai-settings-content"); const arrow = refs.aiSettingsArrow || panel.querySelector("#ldb-ai-settings-arrow"); content.classList.toggle("collapsed"); arrow.textContent = content.classList.contains("collapsed") ? "▶" : "▼"; }; // 折叠 GitHub 设置 (refs.githubSettingsToggle || panel.querySelector("#ldb-github-settings-toggle")).onclick = () => { const content = refs.githubSettingsContent || panel.querySelector("#ldb-github-settings-content"); const arrow = refs.githubSettingsArrow || panel.querySelector("#ldb-github-settings-arrow"); content.classList.toggle("collapsed"); arrow.textContent = content.classList.contains("collapsed") ? "▶" : "▼"; }; (refs.sourceSettingsToggle || panel.querySelector("#ldb-source-settings-toggle")).onclick = () => { const content = refs.sourceSettingsContent || panel.querySelector("#ldb-source-settings-content"); const arrow = refs.sourceSettingsArrow || panel.querySelector("#ldb-source-settings-arrow"); content.classList.toggle("collapsed"); arrow.textContent = content.classList.contains("collapsed") ? "▶" : "▼"; }; (refs.sourcePartitionsToggle || panel.querySelector("#ldb-source-partitions-toggle")).onclick = () => { const content = refs.sourcePartitionsContent || panel.querySelector("#ldb-source-partitions-content"); const arrow = refs.sourcePartitionsArrow || panel.querySelector("#ldb-source-partitions-arrow"); content.classList.toggle("collapsed"); arrow.textContent = content.classList.contains("collapsed") ? "▶" : "▼"; }; (refs.sourceSelectLinuxdo || panel.querySelector("#ldb-source-select-linuxdo")).onclick = () => { UI.switchBookmarkSource("linuxdo"); }; (refs.sourceSelectGithub || panel.querySelector("#ldb-source-select-github")).onclick = () => { UI.switchBookmarkSource("github"); }; (refs.openGithubSettingsBtn || panel.querySelector("#ldb-open-github-settings")).onclick = () => { const settingsTab = panel.querySelector('.ldb-tab[data-tab="settings"]'); if (settingsTab && !settingsTab.classList.contains("active")) { settingsTab.click(); } const content = refs.githubSettingsContent || panel.querySelector("#ldb-github-settings-content"); const arrow = refs.githubSettingsArrow || panel.querySelector("#ldb-github-settings-arrow"); const tokenInput = refs.githubTokenInput || panel.querySelector("#ldb-github-token"); if (content?.classList.contains("collapsed")) { content.classList.remove("collapsed"); if (arrow) arrow.textContent = "▼"; } if (tokenInput) { tokenInput.scrollIntoView({ block: "center", behavior: "smooth" }); tokenInput.focus(); } UI.showStatus("已定位到 GitHub Token 设置", "info"); }; (refs.selfCheckBtn || panel.querySelector("#ldb-self-check-btn")).onclick = () => { UI.renderSelfCheckResult(); UI.showStatus("自检已完成", "info"); }; (refs.copyDiagBtn || panel.querySelector("#ldb-copy-diagnostics-btn")).onclick = async () => { await UI.copyDiagnostics(); }; // 导出目标类型切换 const handleExportTargetChange = (e) => { const targetType = e.target.value; const parentPageGroup = refs.parentPageGroup || panel.querySelector("#ldb-parent-page-group"); const manualDbWrap = refs.manualDbWrap || panel.querySelector("#ldb-manual-db-wrap"); const exportTargetTip = refs.exportTargetTip || panel.querySelector("#ldb-export-target-tip"); if (targetType === "page") { parentPageGroup.style.display = "block"; manualDbWrap.style.display = "none"; exportTargetTip.textContent = "导出为子页面,包含完整内容"; } else { parentPageGroup.style.display = "none"; exportTargetTip.textContent = "导出为数据库条目,支持筛选和排序"; } Storage.set(CONFIG.STORAGE_KEYS.EXPORT_TARGET_TYPE, targetType); }; (refs.exportTargetDatabaseRadio || panel.querySelector("#ldb-export-target-database")).onchange = handleExportTargetChange; (refs.exportTargetPageRadio || panel.querySelector("#ldb-export-target-page")).onchange = handleExportTargetChange; // 父页面 ID 自动保存 (refs.parentPageIdInput || panel.querySelector("#ldb-parent-page-id")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, e.target.value.trim()); }; // 验证配置 (refs.validateConfigBtn || panel.querySelector("#ldb-validate-config")).onclick = async () => { const btn = refs.validateConfigBtn || panel.querySelector("#ldb-validate-config"); const statusSpan = refs.configStatus || panel.querySelector("#ldb-config-status"); const apiKey = (refs.apiKeyInput || panel.querySelector("#ldb-api-key")).value.trim(); const exportTargetType = (refs.exportTargetPageRadio || panel.querySelector("#ldb-export-target-page")).checked ? "page" : "database"; const databaseId = (refs.databaseIdInput || panel.querySelector("#ldb-database-id")).value.trim(); const parentPageId = (refs.parentPageIdInput || panel.querySelector("#ldb-parent-page-id")).value.trim(); // 清除之前的状态 statusSpan.textContent = ""; statusSpan.style.color = ""; if (!apiKey) { UI.showStatus("请填写 API Key", "error"); return; } if (exportTargetType === "database" && !databaseId) { UI.showStatus("请填写数据库 ID", "error"); return; } if (exportTargetType === "page" && !parentPageId) { UI.showStatus("请填写父页面 ID", "error"); return; } btn.disabled = true; btn.innerHTML = '🔄 验证中...'; try { let result; if (exportTargetType === "database") { result = await NotionAPI.validateConfig(apiKey, databaseId); if (result.valid) { statusSpan.textContent = "✅ 验证成功"; statusSpan.style.color = "#34d399"; Storage.set(CONFIG.STORAGE_KEYS.NOTION_API_KEY, apiKey); Storage.set(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, databaseId); } } else { result = await NotionAPI.validatePage(parentPageId, apiKey); if (result.valid) { statusSpan.textContent = "✅ 验证成功"; statusSpan.style.color = "#34d399"; Storage.set(CONFIG.STORAGE_KEYS.NOTION_API_KEY, apiKey); Storage.set(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, parentPageId); } } if (!result.valid) { statusSpan.textContent = `❌ ${result.error}`; statusSpan.style.color = "#f87171"; } } catch (error) { statusSpan.textContent = `❌ ${error.message}`; statusSpan.style.color = "#f87171"; } finally { btn.disabled = false; btn.innerHTML = "验证配置"; } }; // 自动设置数据库属性 (refs.setupDatabaseBtn || panel.querySelector("#ldb-setup-database")).onclick = async () => { const apiKey = (refs.apiKeyInput || panel.querySelector("#ldb-api-key")).value.trim(); const databaseId = (refs.databaseIdInput || panel.querySelector("#ldb-database-id")).value.trim(); const statusSpan = refs.configStatus || panel.querySelector("#ldb-config-status"); // 清除之前的状态 statusSpan.textContent = ""; statusSpan.style.color = ""; if (!apiKey) { UI.showStatus("请先填写 API Key", "error"); return; } if (!databaseId) { UI.showStatus("请先填写数据库 ID", "error"); return; } const btn = refs.setupDatabaseBtn || panel.querySelector("#ldb-setup-database"); btn.disabled = true; btn.innerHTML = '🔄 设置中...'; try { const result = await NotionAPI.setupDatabaseProperties(databaseId, apiKey); if (result.success) { statusSpan.textContent = `✅ ${result.message}`; statusSpan.style.color = "#34d399"; // 保存配置 Storage.set(CONFIG.STORAGE_KEYS.NOTION_API_KEY, apiKey); Storage.set(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, databaseId); } else { statusSpan.textContent = `❌ ${result.error}`; statusSpan.style.color = "#f87171"; } } catch (error) { statusSpan.textContent = `❌ ${error.message}`; statusSpan.style.color = "#f87171"; } finally { btn.disabled = false; btn.innerHTML = "自动设置数据库"; } }; // 自动导入设置 (refs.autoImportEnabled || panel.querySelector("#ldb-auto-import-enabled")).onchange = (e) => { const enabled = e.target.checked; const cfg = UI.getAutoImportConfigBySource(); Storage.set(cfg.enabledKey, enabled); (refs.autoImportOptions || panel.querySelector("#ldb-auto-import-options")).style.display = enabled ? "block" : "none"; if (enabled) { if (cfg.isGitHub) { GitHubAutoImporter.run(); const interval = parseInt((refs.autoImportInterval || panel.querySelector("#ldb-auto-import-interval")).value) || 0; Storage.set(cfg.intervalKey, interval); if (interval > 0) GitHubAutoImporter.startPolling(interval); return; } // 检查 Notion 配置是否完整 const apiKey = (refs.apiKeyInput || panel.querySelector("#ldb-api-key")).value.trim(); if (!apiKey) { AutoImporter.updateStatus("⚠️ 请先配置 Notion API Key"); return; } const exportTargetType = (refs.exportTargetPageRadio || panel.querySelector("#ldb-export-target-page")).checked ? "page" : "database"; if (exportTargetType === "database" && !(refs.databaseIdInput || panel.querySelector("#ldb-database-id")).value.trim()) { AutoImporter.updateStatus("⚠️ 请先配置 Notion 数据库 ID"); return; } if (exportTargetType === "page" && !(refs.parentPageIdInput || panel.querySelector("#ldb-parent-page-id")).value.trim()) { AutoImporter.updateStatus("⚠️ 请先配置父页面 ID"); return; } AutoImporter.run(); const interval = parseInt((refs.autoImportInterval || panel.querySelector("#ldb-auto-import-interval")).value) || 0; Storage.set(cfg.intervalKey, interval); if (interval > 0) AutoImporter.startPolling(interval); } else { if (cfg.isGitHub) { GitHubAutoImporter.stopPolling(); GitHubAutoImporter.updateStatus(""); } else { AutoImporter.stopPolling(); AutoImporter.updateStatus(""); } } }; (refs.autoImportInterval || panel.querySelector("#ldb-auto-import-interval")).onchange = (e) => { const interval = parseInt(e.target.value) || 0; const cfg = UI.getAutoImportConfigBySource(); Storage.set(cfg.intervalKey, interval); if (cfg.isGitHub) { GitHubAutoImporter.stopPolling(); if (interval > 0 && Storage.get(cfg.enabledKey, false)) { GitHubAutoImporter.startPolling(interval); } } else { AutoImporter.stopPolling(); if (interval > 0 && Storage.get(cfg.enabledKey, false)) { AutoImporter.startPolling(interval); } } }; (refs.linuxdoDedupModeSelect || panel.querySelector("#ldb-linuxdo-dedup-mode")).onchange = (e) => { const mode = e.target.value === "allow_duplicates" ? "allow_duplicates" : "strict"; Storage.set(CONFIG.STORAGE_KEYS.LINUXDO_IMPORT_DEDUP_MODE, mode); UI.recomputeExportStats(); UI.renderBookmarkList(); }; (refs.bookmarkDedupModeSelect || panel.querySelector("#ldb-bookmark-dedup-mode")).onchange = (e) => { const mode = e.target.value === "allow_duplicates" ? "allow_duplicates" : "strict"; Storage.set(CONFIG.STORAGE_KEYS.BOOKMARK_IMPORT_DEDUP_MODE, mode); }; (refs.aiCategoryAutoDedupCheckbox || panel.querySelector("#ldb-ai-category-auto-dedup")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.AI_CATEGORY_AUTO_DEDUP, !!e.target.checked); }; (refs.updateCheckBtn || panel.querySelector("#ldb-update-check-btn")).onclick = async () => { await UpdateChecker.check({ manual: true }); }; (refs.updateAutoEnabled || panel.querySelector("#ldb-update-auto-enabled")).onchange = (e) => { const enabled = e.target.checked; const optionsEl = refs.updateAutoOptions || panel.querySelector("#ldb-update-auto-options"); optionsEl.style.display = enabled ? "block" : "none"; Storage.set(CONFIG.STORAGE_KEYS.UPDATE_AUTO_CHECK_ENABLED, enabled); if (enabled) { const hours = parseInt((refs.updateIntervalHours || panel.querySelector("#ldb-update-interval-hours")).value, 10) || CONFIG.DEFAULTS.updateCheckIntervalHours; Storage.set(CONFIG.STORAGE_KEYS.UPDATE_CHECK_INTERVAL_HOURS, hours); UpdateChecker.check({ manual: false }); UpdateChecker.startPolling(hours); } else { UpdateChecker.stopPolling(); } }; (refs.updateIntervalHours || panel.querySelector("#ldb-update-interval-hours")).onchange = (e) => { const hours = parseInt(e.target.value, 10) || CONFIG.DEFAULTS.updateCheckIntervalHours; Storage.set(CONFIG.STORAGE_KEYS.UPDATE_CHECK_INTERVAL_HOURS, hours); if (Storage.get(CONFIG.STORAGE_KEYS.UPDATE_AUTO_CHECK_ENABLED, CONFIG.DEFAULTS.updateAutoCheckEnabled)) { UpdateChecker.startPolling(hours); } }; UI.switchBookmarkSource = (source) => { const resolvedSource = source === "github" ? "github" : "linuxdo"; Storage.set(CONFIG.STORAGE_KEYS.BOOKMARK_SOURCE, resolvedSource); UI.applyBookmarkSourceUI(resolvedSource); UI.renderSelfCheckResult(); UI.bookmarks = []; UI.selectedBookmarks = new Set(); UI.recomputeExportStats(); ((UI.refs && UI.refs.bookmarkCount) || panel.querySelector("#ldb-bookmark-count")).textContent = "-"; ((UI.refs && UI.refs.exportBtn) || panel.querySelector("#ldb-export")).disabled = true; ((UI.refs && UI.refs.bookmarkListContainer) || panel.querySelector("#ldb-bookmark-list-container")).style.display = "none"; UI.renderBookmarkList(); const cfg = UI.getAutoImportConfigBySource(); const autoImportEnabled = Storage.get(cfg.enabledKey, cfg.enabledDefault); const autoImportEnabledEl = refs.autoImportEnabled || panel.querySelector("#ldb-auto-import-enabled"); const autoImportOptionsEl = refs.autoImportOptions || panel.querySelector("#ldb-auto-import-options"); const intervalEl = refs.autoImportInterval || panel.querySelector("#ldb-auto-import-interval"); autoImportEnabledEl.checked = autoImportEnabled; autoImportOptionsEl.style.display = autoImportEnabled ? "block" : "none"; intervalEl.value = String(Storage.get(cfg.intervalKey, cfg.intervalDefault)); if (intervalEl.selectedIndex === -1) { intervalEl.value = String(cfg.intervalDefault); Storage.set(cfg.intervalKey, cfg.intervalDefault); } }; // 收藏列表事件委托(避免每次重渲染重复绑定) if (!UI.bookmarkListBound) { const bookmarkList = (UI.refs && UI.refs.bookmarkList) || panel.querySelector("#ldb-bookmark-list"); bookmarkList.addEventListener("click", (e) => { const item = e.target.closest(".ldb-bookmark-item"); if (!item) return; if (e.target.tagName === "INPUT") return; const checkbox = item.querySelector('input[type="checkbox"]'); if (!checkbox || checkbox.disabled) return; checkbox.checked = !checkbox.checked; checkbox.dispatchEvent(new Event("change", { bubbles: true })); }); bookmarkList.addEventListener("change", (e) => { const checkbox = e.target; if (!(checkbox instanceof HTMLInputElement) || checkbox.type !== "checkbox") return; const item = checkbox.closest(".ldb-bookmark-item"); if (!item) return; const bookmarkKey = String(item.dataset.topicId || ""); if (!bookmarkKey) return; const isUnexported = !UI.isBookmarkKeyExported(bookmarkKey); if (checkbox.checked) { UI.selectedBookmarks.add(bookmarkKey); if (isUnexported) UI.selectedUnexportedCount++; } else { UI.selectedBookmarks.delete(bookmarkKey); if (isUnexported) UI.selectedUnexportedCount = Math.max(0, UI.selectedUnexportedCount - 1); } UI.updateSelectCount(); }); UI.bookmarkListBound = true; } // 加载收藏 (refs.loadBookmarksBtn || panel.querySelector("#ldb-load-bookmarks")).onclick = async () => { const btn = refs.loadBookmarksBtn || panel.querySelector("#ldb-load-bookmarks"); btn.disabled = true; btn.innerHTML = '🔄 加载中...'; try { let bookmarks = []; if (UI.isActiveGitHubSource()) { const username = (refs.githubUsernameInput || panel.querySelector("#ldb-github-username")).value.trim() || Storage.get(CONFIG.STORAGE_KEYS.GITHUB_USERNAME, ""); const token = (refs.githubTokenInput || panel.querySelector("#ldb-github-token")).value.trim() || Storage.get(CONFIG.STORAGE_KEYS.GITHUB_TOKEN, ""); const types = GitHubAPI.getImportTypes(); if (!username && !token) { UI.showStatus("请先在设置中填写 GitHub 用户名(或配置 Token)", "error"); return; } const allItems = []; for (const type of types) { if (type === "stars") { const items = await GitHubAPI.fetchStarredRepos(username, token); allItems.push(...UI.mapGitHubItemsToBookmarks(items, "stars")); } else if (type === "repos") { const items = await GitHubAPI.fetchUserRepos(username, token); const ownRepos = items.filter(r => !r.fork); allItems.push(...UI.mapGitHubItemsToBookmarks(ownRepos, "repos")); } else if (type === "forks") { const items = await GitHubAPI.fetchForkedRepos(username, token); allItems.push(...UI.mapGitHubItemsToBookmarks(items, "forks")); } else if (type === "gists") { const items = await GitHubAPI.fetchUserGists(username, token); allItems.push(...UI.mapGitHubItemsToBookmarks(items, "gists")); } ((UI.refs && UI.refs.bookmarkCount) || panel.querySelector("#ldb-bookmark-count")).textContent = allItems.length; } bookmarks = allItems; } else { const username = Utils.getCurrentLinuxDoUsername(); if (!username) { UI.showStatus("无法获取当前 Linux.do 用户名,请先登录后重试", "error"); return; } bookmarks = await LinuxDoAPI.fetchAllBookmarks(username, (count) => { ((UI.refs && UI.refs.bookmarkCount) || panel.querySelector("#ldb-bookmark-count")).textContent = count; }); } UI.bookmarks = bookmarks; UI.selectedBookmarks = new Set(bookmarks.map(b => UI.getBookmarkKey(b))); UI.recomputeExportStats(); ((UI.refs && UI.refs.bookmarkCount) || panel.querySelector("#ldb-bookmark-count")).textContent = bookmarks.length; ((UI.refs && UI.refs.exportBtn) || panel.querySelector("#ldb-export")).disabled = false; // 渲染收藏列表 UI.renderBookmarkList(); ((UI.refs && UI.refs.bookmarkListContainer) || panel.querySelector("#ldb-bookmark-list-container")).style.display = "block"; const sourceText = UI.isActiveGitHubSource() ? "GitHub 收藏" : "Linux.do 收藏"; UI.showStatus(`成功加载 ${bookmarks.length} 个${sourceText}`, "success"); } catch (error) { UI.showStatus(`加载失败: ${error.message}`, "error"); } finally { btn.disabled = false; btn.innerHTML = "🔄 加载收藏列表"; } }; (refs.importBrowserBookmarksBtn || panel.querySelector("#ldb-import-browser-bookmarks")).onclick = async () => { const btn = refs.importBrowserBookmarksBtn || panel.querySelector("#ldb-import-browser-bookmarks"); const source = UI.getActiveBookmarkSource(); if (source !== "linuxdo") { UI.switchBookmarkSource("linuxdo"); const toggle = refs.sourceSettingsToggle || panel.querySelector("#ldb-source-settings-toggle"); const content = refs.sourceSettingsContent || panel.querySelector("#ldb-source-settings-content"); if (toggle && content?.classList.contains("collapsed")) { toggle.click(); } } btn.disabled = true; btn.innerHTML = '🔄 导入中...'; try { const chatInput = panel.querySelector("#ldb-chat-input"); if (chatInput && typeof ChatUI !== "undefined" && ChatUI.sendMessage) { chatInput.value = "导入浏览器书签"; ChatUI.sendMessage(); } else { UI.showStatus("AI 面板未就绪,请稍后重试", "error"); } } finally { btn.disabled = false; btn.innerHTML = "📖 导入浏览器书签"; } }; // 全选/取消 (refs.selectAll || panel.querySelector("#ldb-select-all")).onchange = (e) => { const checked = e.target.checked; if (checked) { UI.selectedBookmarks = new Set(UI.bookmarks.map(b => UI.getBookmarkKey(b))); } else { UI.selectedBookmarks = new Set(); } UI.recomputeExportStats(); UI.renderBookmarkList(); UI.updateSelectCount(); }; // 暂停按钮 (refs.pauseBtn || panel.querySelector("#ldb-pause")).onclick = () => { const pauseBtn = refs.pauseBtn || panel.querySelector("#ldb-pause"); if (Exporter.isPaused) { Exporter.resume(); pauseBtn.innerHTML = "⏸️ 暂停"; pauseBtn.classList.remove("ldb-btn-primary"); pauseBtn.classList.add("ldb-btn-warning"); } else { Exporter.pause(); pauseBtn.innerHTML = "▶️ 继续"; pauseBtn.classList.remove("ldb-btn-warning"); pauseBtn.classList.add("ldb-btn-primary"); } }; // 取消按钮 (refs.cancelBtn || panel.querySelector("#ldb-cancel")).onclick = () => { if (confirm("确定要取消导出吗?已导出的内容不会被删除。")) { Exporter.cancel(); } }; // 开始导出 (refs.exportBtn || panel.querySelector("#ldb-export")).onclick = async () => { const apiKey = (refs.apiKeyInput || panel.querySelector("#ldb-api-key")).value.trim(); const exportTargetType = (refs.exportTargetPageRadio || panel.querySelector("#ldb-export-target-page")).checked ? "page" : "database"; const databaseId = (refs.databaseIdInput || panel.querySelector("#ldb-database-id")).value.trim(); const parentPageId = (refs.parentPageIdInput || panel.querySelector("#ldb-parent-page-id")).value.trim(); if (!apiKey) { UI.showStatus("请先配置 Notion API Key", "error"); return; } if (exportTargetType === "database" && !databaseId) { UI.showStatus("请先配置数据库 ID", "error"); return; } if (exportTargetType === "page" && !parentPageId) { UI.showStatus("请先配置父页面 ID", "error"); return; } if (!UI.bookmarks || UI.bookmarks.length === 0) { UI.showStatus("请先加载收藏列表", "error"); return; } // 获取选中的收藏(严格模式过滤已导出,允许重复模式仅按勾选) const toExport = UI.bookmarks.filter((b) => { const bookmarkKey = UI.getBookmarkKey(b); return UI.selectedBookmarks.has(bookmarkKey) && !UI.isBookmarkKeyExported(bookmarkKey); }); if (toExport.length === 0) { UI.showStatus("没有可导出的收藏(可能都已导出过或未选中)", "info"); return; } const settings = { apiKey, databaseId, parentPageId, exportTargetType, onlyFirst: (refs.onlyFirstCheckbox || panel.querySelector("#ldb-only-first")).checked, onlyOp: (refs.onlyOpCheckbox || panel.querySelector("#ldb-only-op")).checked, rangeStart: parseInt((refs.rangeStartInput || panel.querySelector("#ldb-range-start")).value) || 1, rangeEnd: parseInt((refs.rangeEndInput || panel.querySelector("#ldb-range-end")).value) || 999999, imgMode: (refs.imgModeSelect || panel.querySelector("#ldb-img-mode")).value, concurrency: parseInt((refs.exportConcurrencySelect || panel.querySelector("#ldb-export-concurrency")).value) || 1, aiApiKey: (refs.aiApiKeyInput || panel.querySelector("#ldb-ai-api-key")).value.trim(), aiService: (refs.aiServiceSelect || panel.querySelector("#ldb-ai-service")).value, aiModel: (refs.aiModelSelect || panel.querySelector("#ldb-ai-model")).value, aiBaseUrl: (refs.aiBaseUrlInput || panel.querySelector("#ldb-ai-base-url")).value.trim(), categories: Utils.parseAICategories( (refs.aiCategoriesInput || panel.querySelector("#ldb-ai-categories")).value.trim() || "" ), githubUsername: (refs.githubUsernameInput || panel.querySelector("#ldb-github-username")).value.trim(), token: (refs.githubTokenInput || panel.querySelector("#ldb-github-token")).value.trim(), }; // 保存设置 Storage.set(CONFIG.STORAGE_KEYS.NOTION_API_KEY, apiKey); Storage.set(CONFIG.STORAGE_KEYS.EXPORT_TARGET_TYPE, exportTargetType); if (exportTargetType === "database") { Storage.set(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, databaseId); } else { Storage.set(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, parentPageId); } Storage.set(CONFIG.STORAGE_KEYS.FILTER_ONLY_FIRST, settings.onlyFirst); Storage.set(CONFIG.STORAGE_KEYS.FILTER_ONLY_OP, settings.onlyOp); Storage.set(CONFIG.STORAGE_KEYS.FILTER_RANGE_START, settings.rangeStart); Storage.set(CONFIG.STORAGE_KEYS.FILTER_RANGE_END, settings.rangeEnd); Storage.set(CONFIG.STORAGE_KEYS.IMG_MODE, settings.imgMode); Storage.set(CONFIG.STORAGE_KEYS.REQUEST_DELAY, parseInt((refs.requestDelaySelect || panel.querySelector("#ldb-request-delay")).value)); Storage.set(CONFIG.STORAGE_KEYS.EXPORT_CONCURRENCY, settings.concurrency); // 显示控制按钮,隐藏导出按钮 (refs.exportBtns || panel.querySelector("#ldb-export-btns")).style.display = "none"; (refs.controlBtns || panel.querySelector("#ldb-control-btns")).style.display = "flex"; (refs.pauseBtn || panel.querySelector("#ldb-pause")).innerHTML = "⏸️ 暂停"; (refs.pauseBtn || panel.querySelector("#ldb-pause")).classList.add("ldb-btn-warning"); (refs.pauseBtn || panel.querySelector("#ldb-pause")).classList.remove("ldb-btn-primary"); // 清空之前的报告 ((UI.refs && UI.refs.reportContainer) || panel.querySelector("#ldb-report-container")).innerHTML = ""; try { let results; if (UI.isActiveGitHubSource()) { results = await UI.exportGitHubSelected(toExport, settings, (current, total, title) => { UI.showProgress(current, total, `${title}\n导出中`); }); } else { results = await Exporter.exportBookmarks(toExport, settings, (progress) => { UI.showProgress( progress.current, progress.total, `${progress.title}\n${progress.message || progress.stage}${progress.isPaused ? " (已暂停)" : ""}` ); }); } UI.hideProgress(); // 显示导出报告 UI.showReport(results); // 刷新列表状态 UI.renderBookmarkList(); const successCount = results.success.length; const failCount = results.failed.length; const skippedCount = results.skipped?.length || 0; let statusMsg = `导出完成:成功 ${successCount} 个`; if (failCount > 0) statusMsg += `,失败 ${failCount} 个`; if (skippedCount > 0) statusMsg += `,跳过 ${skippedCount} 个`; UI.showStatus(statusMsg, failCount > successCount ? "error" : "success"); // 通知 if (typeof GM_notification === "function") { GM_notification({ title: "导出完成", text: statusMsg, timeout: 5000, }); } } catch (error) { UI.showStatus(`导出出错: ${error.message}`, "error"); } finally { // 恢复按钮状态 (refs.exportBtns || panel.querySelector("#ldb-export-btns")).style.display = "flex"; (refs.controlBtns || panel.querySelector("#ldb-control-btns")).style.display = "none"; Exporter.reset(); } }; // 权限设置事件 (refs.permissionLevelSelect || panel.querySelector("#ldb-permission-level")).onchange = (e) => { const level = parseInt(e.target.value); OperationGuard.setLevel(level); UI.showStatus(`权限级别已设置为: ${CONFIG.PERMISSION_NAMES[level]}`, "success"); }; (refs.requireConfirmCheckbox || panel.querySelector("#ldb-require-confirm")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.REQUIRE_CONFIRM, e.target.checked); }; (refs.enableAuditLogCheckbox || panel.querySelector("#ldb-enable-audit-log")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.ENABLE_AUDIT_LOG, e.target.checked); // 更新日志面板可见性 const logPanel = refs.logPanel || panel.querySelector("#ldb-log-panel"); if (logPanel) { logPanel.style.display = e.target.checked ? "block" : "none"; } }; // 日志面板事件 (refs.logToggleBtn || panel.querySelector("#ldb-log-toggle")).onclick = () => { const content = refs.logContent || panel.querySelector("#ldb-log-content"); const arrow = refs.logArrow || panel.querySelector("#ldb-log-arrow"); content.classList.toggle("collapsed"); arrow.textContent = content.classList.contains("collapsed") ? "▶" : "▼"; // 展开时更新日志内容 if (!content.classList.contains("collapsed")) { UI.updateLogPanel(); } }; (refs.logClearBtn || panel.querySelector("#ldb-log-clear")).onclick = () => { if (confirm("确定要清除所有操作日志吗?")) { OperationLog.clear(); UI.showStatus("日志已清除", "success"); } }; // 输入框自动保存 (refs.apiKeyInput || panel.querySelector("#ldb-api-key")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.NOTION_API_KEY, e.target.value.trim()); }; (refs.databaseIdInput || panel.querySelector("#ldb-database-id")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, e.target.value.trim()); }; // 手动输入数据库 ID 开关 (refs.toggleManualDbBtn || panel.querySelector("#ldb-toggle-manual-db")).onclick = () => { const wrap = refs.manualDbWrap || panel.querySelector("#ldb-manual-db-wrap"); const visible = wrap.style.display !== "none"; wrap.style.display = visible ? "none" : "block"; }; // 刷新工作区页面列表 (refs.refreshWorkspaceBtn || panel.querySelector("#ldb-refresh-workspace")).onclick = async () => { const apiKey = (refs.apiKeyInput || panel.querySelector("#ldb-api-key")).value.trim(); const refreshBtn = refs.refreshWorkspaceBtn || panel.querySelector("#ldb-refresh-workspace"); const workspaceTip = refs.workspaceTip || panel.querySelector("#ldb-workspace-tip"); if (!apiKey) { UI.showStatus("请先填写 Notion API Key", "error"); return; } refreshBtn.disabled = true; refreshBtn.innerHTML = "⏳"; workspaceTip.style.color = ""; workspaceTip.textContent = "正在获取数据库列表..."; try { const workspace = await WorkspaceService.fetchWorkspaceStaged(apiKey, { includePages: true, onProgress: (progress) => { if (progress.phase === "databases") { workspaceTip.textContent = `正在获取数据库列表... 已加载 ${progress.loaded} 个`; } else if (progress.phase === "pages") { workspaceTip.textContent = `数据库已就绪,正在获取页面... 已加载 ${progress.loaded} 个`; } }, onPhaseComplete: (phase, partialWorkspace) => { const workspaceData = { apiKeyHash: apiKey.slice(-8), databases: partialWorkspace.databases || [], pages: partialWorkspace.pages || [], timestamp: Date.now(), }; Storage.set(CONFIG.STORAGE_KEYS.WORKSPACE_PAGES, JSON.stringify(workspaceData)); UI.updateWorkspaceSelect(workspaceData); if (phase === "databases") { workspaceTip.textContent = `✅ 已加载 ${workspaceData.databases.length} 个数据库,可先选择目标;页面列表继续加载中...`; workspaceTip.style.color = "#34d399"; } }, }); const workspaceData = { apiKeyHash: apiKey.slice(-8), databases: workspace.databases, pages: workspace.pages, timestamp: Date.now(), }; Storage.set(CONFIG.STORAGE_KEYS.WORKSPACE_PAGES, JSON.stringify(workspaceData)); UI.updateWorkspaceSelect(workspaceData); workspaceTip.textContent = `✅ 获取到 ${workspace.databases.length} 个数据库,${workspace.pages.length} 个页面`; workspaceTip.style.color = "#34d399"; } catch (error) { workspaceTip.textContent = `❌ ${error.message}`; workspaceTip.style.color = "#f87171"; } finally { refreshBtn.disabled = false; refreshBtn.innerHTML = "🔄"; } }; // 从工作区选择页面/数据库 (refs.workspaceSelect || panel.querySelector("#ldb-workspace-select")).onchange = (e) => { const selected = e.target.value; if (selected) { const [type, id] = selected.split(":"); if (type === "database") { (refs.databaseIdInput || panel.querySelector("#ldb-database-id")).value = id; Storage.set(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, id); } else if (type === "page") { // 页面类型:填入父页面 ID 字段 (refs.parentPageIdInput || panel.querySelector("#ldb-parent-page-id")).value = id; Storage.set(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, id); // 自动切换到页面导出模式 (refs.exportTargetPageRadio || panel.querySelector("#ldb-export-target-page")).checked = true; (refs.parentPageGroup || panel.querySelector("#ldb-parent-page-group")).style.display = "block"; (refs.manualDbWrap || panel.querySelector("#ldb-manual-db-wrap")).style.display = "none"; (refs.exportTargetTip || panel.querySelector("#ldb-export-target-tip")).textContent = "导出为子页面,包含完整内容"; Storage.set(CONFIG.STORAGE_KEYS.EXPORT_TARGET_TYPE, "page"); UI.showStatus("已选择页面,自动切换为页面导出模式", "info"); } } }; // =========================================== // AI 对话事件绑定 // =========================================== // 初始化对话 UI ChatUI.init(); // AI 服务切换 - 更新模型列表(优先使用缓存) (refs.aiServiceSelect || panel.querySelector("#ldb-ai-service")).onchange = (e) => { const newService = e.target.value; // 优先使用缓存的模型列表 const cachedModels = Storage.get(CONFIG.STORAGE_KEYS.FETCHED_MODELS, "{}"); try { const modelsData = JSON.parse(cachedModels); if (modelsData[newService]?.models?.length > 0) { UI.updateAIModelOptions(newService, modelsData[newService].models); } else { UI.updateAIModelOptions(newService); } } catch { UI.updateAIModelOptions(newService); } Storage.set(CONFIG.STORAGE_KEYS.AI_SERVICE, newService); }; // 保存 AI 配置 (refs.aiApiKeyInput || panel.querySelector("#ldb-ai-api-key")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.AI_API_KEY, e.target.value.trim()); }; (refs.aiBaseUrlInput || panel.querySelector("#ldb-ai-base-url")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.AI_BASE_URL, e.target.value.trim()); }; (refs.aiCategoriesInput || panel.querySelector("#ldb-ai-categories")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.AI_CATEGORIES, e.target.value.trim()); }; (refs.aiModelSelect || panel.querySelector("#ldb-ai-model")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.AI_MODEL, e.target.value); }; // AI 查询目标数据库选择 (refs.aiTargetDbSelect || panel.querySelector("#ldb-ai-target-db")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.AI_TARGET_DB, e.target.value); }; (refs.workspaceMaxPagesSelect || panel.querySelector("#ldb-workspace-max-pages")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.WORKSPACE_MAX_PAGES, parseInt(e.target.value) || 0); }; // Agent 个性化设置 (refs.agentPersonaNameInput || panel.querySelector("#ldb-agent-persona-name")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.AGENT_PERSONA_NAME, e.target.value.trim() || CONFIG.DEFAULTS.agentPersonaName); }; (refs.agentPersonaToneSelect || panel.querySelector("#ldb-agent-persona-tone")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.AGENT_PERSONA_TONE, e.target.value); }; (refs.agentPersonaExpertiseInput || panel.querySelector("#ldb-agent-persona-expertise")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.AGENT_PERSONA_EXPERTISE, e.target.value.trim() || CONFIG.DEFAULTS.agentPersonaExpertise); }; (refs.agentPersonaInstructionsInput || panel.querySelector("#ldb-agent-persona-instructions")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.AGENT_PERSONA_INSTRUCTIONS, e.target.value.trim()); }; (refs.githubUsernameInput || panel.querySelector("#ldb-github-username")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.GITHUB_USERNAME, e.target.value.trim()); }; (refs.githubTokenInput || panel.querySelector("#ldb-github-token")).onchange = (e) => { Storage.set(CONFIG.STORAGE_KEYS.GITHUB_TOKEN, e.target.value.trim()); }; // GitHub 导入类型 (refs.githubTypeCheckboxes || panel.querySelectorAll(".ldb-github-type")).forEach(cb => { cb.onchange = () => { const source = refs.githubTypeCheckboxes || panel.querySelectorAll(".ldb-github-type:checked"); const types = [...source].filter(c => c.checked).map(c => c.value); GitHubAPI.setImportTypes(types.length > 0 ? types : ["stars"]); }; }); // 刷新 AI 数据库列表 (refs.aiRefreshDbsBtn || panel.querySelector("#ldb-ai-refresh-dbs")).onclick = async () => { const apiKey = (refs.apiKeyInput || panel.querySelector("#ldb-api-key")).value.trim(); const refreshBtn = refs.aiRefreshDbsBtn || panel.querySelector("#ldb-ai-refresh-dbs"); if (!apiKey) { UI.showStatus("请先填写 Notion API Key", "error"); return; } refreshBtn.disabled = true; refreshBtn.innerHTML = "⏳"; try { const workspace = await WorkspaceService.fetchWorkspace(apiKey, { includePages: false }); const apiKeyHash = apiKey.slice(-8); const cachedWorkspace = Storage.get(CONFIG.STORAGE_KEYS.WORKSPACE_PAGES, "{}"); let workspaceData; try { workspaceData = JSON.parse(cachedWorkspace); } catch { workspaceData = {}; } workspaceData.apiKeyHash = apiKeyHash; workspaceData.databases = workspace.databases; workspaceData.timestamp = Date.now(); Storage.set(CONFIG.STORAGE_KEYS.WORKSPACE_PAGES, JSON.stringify(workspaceData)); UI.updateAITargetDbOptions(workspace.databases); UI.showStatus(`获取到 ${workspace.databases.length} 个数据库`, "success"); } catch (error) { UI.showStatus(`获取数据库列表失败: ${error.message}`, "error"); } finally { refreshBtn.disabled = false; refreshBtn.innerHTML = "🔄"; } }; // 获取模型列表 (refs.aiFetchModelsBtn || panel.querySelector("#ldb-ai-fetch-models")).onclick = async () => { const aiApiKey = (refs.aiApiKeyInput || panel.querySelector("#ldb-ai-api-key")).value.trim(); const aiService = (refs.aiServiceSelect || panel.querySelector("#ldb-ai-service")).value; const aiBaseUrl = (refs.aiBaseUrlInput || panel.querySelector("#ldb-ai-base-url")).value.trim(); const fetchBtn = refs.aiFetchModelsBtn || panel.querySelector("#ldb-ai-fetch-models"); const modelTip = refs.aiModelTip || panel.querySelector("#ldb-ai-model-tip"); if (!aiApiKey) { UI.showStatus("请先填写 AI API Key", "error"); return; } fetchBtn.disabled = true; fetchBtn.innerHTML = "⏳ 获取中..."; modelTip.textContent = ""; try { const models = await AIService.fetchModels(aiService, aiApiKey, aiBaseUrl); UI.updateAIModelOptions(aiService, models, true); // 保留当前选择 // 持久化保存获取的模型列表 const cachedModels = Storage.get(CONFIG.STORAGE_KEYS.FETCHED_MODELS, "{}"); const modelsData = JSON.parse(cachedModels); modelsData[aiService] = { models, timestamp: Date.now() }; Storage.set(CONFIG.STORAGE_KEYS.FETCHED_MODELS, JSON.stringify(modelsData)); modelTip.textContent = `✅ 获取到 ${models.length} 个可用模型`; modelTip.style.color = "#34d399"; UI.showStatus(`成功获取 ${models.length} 个模型`, "success"); } catch (error) { modelTip.textContent = `❌ ${error.message}`; modelTip.style.color = "#f87171"; UI.showStatus(`获取模型失败: ${error.message}`, "error"); } finally { fetchBtn.disabled = false; fetchBtn.innerHTML = "🔄 获取"; } }; // 测试 AI 连接 (refs.aiTestBtn || panel.querySelector("#ldb-ai-test")).onclick = async () => { const btn = refs.aiTestBtn || panel.querySelector("#ldb-ai-test"); const statusSpan = refs.aiTestStatus || panel.querySelector("#ldb-ai-test-status"); const aiApiKey = (refs.aiApiKeyInput || panel.querySelector("#ldb-ai-api-key")).value.trim(); const aiService = (refs.aiServiceSelect || panel.querySelector("#ldb-ai-service")).value; const aiModel = (refs.aiModelSelect || panel.querySelector("#ldb-ai-model")).value; const aiBaseUrl = (refs.aiBaseUrlInput || panel.querySelector("#ldb-ai-base-url")).value.trim(); // 清除之前的状态 statusSpan.textContent = ""; statusSpan.style.color = ""; if (!aiApiKey) { UI.showStatus("请先填写 AI API Key", "error"); return; } btn.disabled = true; btn.innerHTML = '🔄 测试中...'; try { const response = await AIService.request( "请回复:连接成功", { aiService, aiApiKey, aiModel, aiBaseUrl } ); statusSpan.textContent = `✅ ${response}`; statusSpan.style.color = "#34d399"; } catch (error) { statusSpan.textContent = `❌ ${error.message}`; statusSpan.style.color = "#f87171"; } finally { btn.disabled = false; btn.innerHTML = "🧪 测试"; } }; // 拖拽 UI.makeDraggable(panel, panel.querySelector(".ldb-header")); }, // 加载配置 loadConfig: () => { const panel = UI.panel; const refs = UI.refs || {}; (refs.apiKeyInput || panel.querySelector("#ldb-api-key")).value = Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, ""); (refs.databaseIdInput || panel.querySelector("#ldb-database-id")).value = Storage.get(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, ""); (refs.parentPageIdInput || panel.querySelector("#ldb-parent-page-id")).value = Storage.get(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, ""); (refs.onlyFirstCheckbox || panel.querySelector("#ldb-only-first")).checked = Storage.get(CONFIG.STORAGE_KEYS.FILTER_ONLY_FIRST, CONFIG.DEFAULTS.onlyFirst); (refs.onlyOpCheckbox || panel.querySelector("#ldb-only-op")).checked = Storage.get(CONFIG.STORAGE_KEYS.FILTER_ONLY_OP, CONFIG.DEFAULTS.onlyOp); (refs.rangeStartInput || panel.querySelector("#ldb-range-start")).value = Storage.get(CONFIG.STORAGE_KEYS.FILTER_RANGE_START, CONFIG.DEFAULTS.rangeStart); (refs.rangeEndInput || panel.querySelector("#ldb-range-end")).value = Storage.get(CONFIG.STORAGE_KEYS.FILTER_RANGE_END, CONFIG.DEFAULTS.rangeEnd); (refs.imgModeSelect || panel.querySelector("#ldb-img-mode")).value = Storage.get(CONFIG.STORAGE_KEYS.IMG_MODE, CONFIG.DEFAULTS.imgMode); (refs.requestDelaySelect || panel.querySelector("#ldb-request-delay")).value = Storage.get(CONFIG.STORAGE_KEYS.REQUEST_DELAY, CONFIG.DEFAULTS.requestDelay); (refs.exportConcurrencySelect || panel.querySelector("#ldb-export-concurrency")).value = Storage.get(CONFIG.STORAGE_KEYS.EXPORT_CONCURRENCY, CONFIG.DEFAULTS.exportConcurrency); // 加载导出目标类型设置 const exportTargetType = Storage.get(CONFIG.STORAGE_KEYS.EXPORT_TARGET_TYPE, CONFIG.DEFAULTS.exportTargetType); if (exportTargetType === "page") { (refs.exportTargetPageRadio || panel.querySelector("#ldb-export-target-page")).checked = true; (refs.parentPageGroup || panel.querySelector("#ldb-parent-page-group")).style.display = "block"; (refs.manualDbWrap || panel.querySelector("#ldb-manual-db-wrap")).style.display = "none"; (refs.exportTargetTip || panel.querySelector("#ldb-export-target-tip")).textContent = "导出为子页面,包含完整内容"; } else { (refs.exportTargetDatabaseRadio || panel.querySelector("#ldb-export-target-database")).checked = true; (refs.parentPageGroup || panel.querySelector("#ldb-parent-page-group")).style.display = "none"; (refs.exportTargetTip || panel.querySelector("#ldb-export-target-tip")).textContent = "导出为数据库条目,支持筛选和排序"; } // 加载权限设置 (refs.permissionLevelSelect || panel.querySelector("#ldb-permission-level")).value = Storage.get(CONFIG.STORAGE_KEYS.PERMISSION_LEVEL, CONFIG.DEFAULTS.permissionLevel); (refs.requireConfirmCheckbox || panel.querySelector("#ldb-require-confirm")).checked = Storage.get(CONFIG.STORAGE_KEYS.REQUIRE_CONFIRM, CONFIG.DEFAULTS.requireConfirm); (refs.enableAuditLogCheckbox || panel.querySelector("#ldb-enable-audit-log")).checked = Storage.get(CONFIG.STORAGE_KEYS.ENABLE_AUDIT_LOG, CONFIG.DEFAULTS.enableAuditLog); // 根据审计日志设置更新面板可见性 const enableAuditLog = Storage.get(CONFIG.STORAGE_KEYS.ENABLE_AUDIT_LOG, CONFIG.DEFAULTS.enableAuditLog); const logPanel = refs.logPanel || panel.querySelector("#ldb-log-panel"); if (logPanel) { logPanel.style.display = enableAuditLog ? "block" : "none"; } // 加载 AI 分类设置 const aiService = Storage.get(CONFIG.STORAGE_KEYS.AI_SERVICE, CONFIG.DEFAULTS.aiService); (refs.aiServiceSelect || panel.querySelector("#ldb-ai-service")).value = aiService; // 验证并加载 AI 模型(优先使用缓存的模型列表) const savedModel = Storage.get(CONFIG.STORAGE_KEYS.AI_MODEL, ""); const provider = AIService.PROVIDERS[aiService]; const modelSelect = refs.aiModelSelect || panel.querySelector("#ldb-ai-model"); // 先尝试从缓存加载模型列表 const cachedModels = Storage.get(CONFIG.STORAGE_KEYS.FETCHED_MODELS, "{}"); let validModels = provider?.models || []; try { const modelsData = JSON.parse(cachedModels); if (modelsData[aiService]?.models?.length > 0) { validModels = modelsData[aiService].models; UI.updateAIModelOptions(aiService, validModels); } else { UI.updateAIModelOptions(aiService); } } catch { UI.updateAIModelOptions(aiService); } if (savedModel) { // 检查保存的模型是否在下拉框选项中存在 const optionExists = Array.from(modelSelect.options).some(opt => opt.value === savedModel); if (optionExists || validModels.includes(savedModel)) { // 存储的模型可用,直接设置 modelSelect.value = savedModel; } else { // 存储的模型不兼容当前服务,重置为默认模型 const defaultModel = provider?.defaultModel || ""; modelSelect.value = defaultModel; Storage.set(CONFIG.STORAGE_KEYS.AI_MODEL, defaultModel); console.warn(`AI 模型 "${savedModel}" 与当前服务 "${aiService}" 不兼容,已重置为默认模型`); } } (refs.aiApiKeyInput || panel.querySelector("#ldb-ai-api-key")).value = Storage.get(CONFIG.STORAGE_KEYS.AI_API_KEY, ""); (refs.aiBaseUrlInput || panel.querySelector("#ldb-ai-base-url")).value = Storage.get(CONFIG.STORAGE_KEYS.AI_BASE_URL, CONFIG.DEFAULTS.aiBaseUrl); (refs.aiCategoriesInput || panel.querySelector("#ldb-ai-categories")).value = Storage.get(CONFIG.STORAGE_KEYS.AI_CATEGORIES, CONFIG.DEFAULTS.aiCategories); (refs.workspaceMaxPagesSelect || panel.querySelector("#ldb-workspace-max-pages")).value = Storage.get(CONFIG.STORAGE_KEYS.WORKSPACE_MAX_PAGES, CONFIG.DEFAULTS.workspaceMaxPages); // 加载 Agent 个性化设置 (refs.agentPersonaNameInput || panel.querySelector("#ldb-agent-persona-name")).value = Storage.get(CONFIG.STORAGE_KEYS.AGENT_PERSONA_NAME, CONFIG.DEFAULTS.agentPersonaName); (refs.agentPersonaToneSelect || panel.querySelector("#ldb-agent-persona-tone")).value = Storage.get(CONFIG.STORAGE_KEYS.AGENT_PERSONA_TONE, CONFIG.DEFAULTS.agentPersonaTone); (refs.agentPersonaExpertiseInput || panel.querySelector("#ldb-agent-persona-expertise")).value = Storage.get(CONFIG.STORAGE_KEYS.AGENT_PERSONA_EXPERTISE, CONFIG.DEFAULTS.agentPersonaExpertise); (refs.agentPersonaInstructionsInput || panel.querySelector("#ldb-agent-persona-instructions")).value = Storage.get(CONFIG.STORAGE_KEYS.AGENT_PERSONA_INSTRUCTIONS, CONFIG.DEFAULTS.agentPersonaInstructions); // 加载 GitHub 设置 (refs.githubUsernameInput || panel.querySelector("#ldb-github-username")).value = Storage.get(CONFIG.STORAGE_KEYS.GITHUB_USERNAME, ""); (refs.githubTokenInput || panel.querySelector("#ldb-github-token")).value = Storage.get(CONFIG.STORAGE_KEYS.GITHUB_TOKEN, ""); // 加载 GitHub 导入类型 const savedGHTypesMain = GitHubAPI.getImportTypes(); (refs.githubTypeCheckboxes || panel.querySelectorAll(".ldb-github-type")).forEach(cb => { cb.checked = savedGHTypesMain.includes(cb.value); }); const source = UI.getActiveBookmarkSource(); UI.applyBookmarkSourceUI(source); // 书签扩展状态 const bmStatusMain = refs.bookmarkExtStatus || panel.querySelector("#ldb-bookmark-ext-status"); if (bmStatusMain) { if (BookmarkBridge.isExtensionAvailable()) { const isUserscriptMode = typeof GM_info !== "undefined" && !!GM_info.scriptHandler; if (isUserscriptMode) { bmStatusMain.innerHTML = '✅ 桥接已就绪(Userscript 模式) — 可用「📖 导入浏览器书签」按钮'; } else { bmStatusMain.innerHTML = '✅ 书签能力已就绪(Extension 模式) — 可用「📖 导入浏览器书签」按钮'; } } else { bmStatusMain.innerHTML = `❌ 扩展未安装 — ${InstallHelper.renderInstallLink("一键安装浏览器扩展")}`; } } UI.renderSelfCheckResult(); // 加载 AI 查询目标数据库设置 const cachedWorkspaceForDb = Storage.get(CONFIG.STORAGE_KEYS.WORKSPACE_PAGES, "{}"); try { const wsData = JSON.parse(cachedWorkspaceForDb); if (wsData.databases?.length > 0) { UI.updateAITargetDbOptions(wsData.databases); } } catch {} const savedTargetDb = Storage.get(CONFIG.STORAGE_KEYS.AI_TARGET_DB, ""); if (savedTargetDb) { (refs.aiTargetDbSelect || panel.querySelector("#ldb-ai-target-db")).value = savedTargetDb; } // 初始化日志面板 UI.updateLogPanel(); // 加载缓存的工作区页面列表(校验 API Key) const cachedWorkspace = Storage.get(CONFIG.STORAGE_KEYS.WORKSPACE_PAGES, "{}"); try { const workspaceData = JSON.parse(cachedWorkspace); const currentApiKey = (refs.apiKeyInput || panel.querySelector("#ldb-api-key")).value.trim(); const currentKeyHash = currentApiKey ? currentApiKey.slice(-8) : ""; // 仅当 API Key 匹配时才显示缓存 if (workspaceData.apiKeyHash === currentKeyHash && (workspaceData.databases?.length > 0 || workspaceData.pages?.length > 0)) { UI.updateWorkspaceSelect(workspaceData); } } catch {} // 加载自动导入设置 const savedSource = Storage.get(CONFIG.STORAGE_KEYS.BOOKMARK_SOURCE, CONFIG.DEFAULTS.bookmarkSource); const resolvedSource = savedSource === "github" ? "github" : "linuxdo"; Storage.set(CONFIG.STORAGE_KEYS.BOOKMARK_SOURCE, resolvedSource); UI.applyBookmarkSourceUI(resolvedSource); const autoConfig = UI.getAutoImportConfigBySource(); const autoImportEnabled = Storage.get(autoConfig.enabledKey, autoConfig.enabledDefault); (refs.autoImportEnabled || panel.querySelector("#ldb-auto-import-enabled")).checked = autoImportEnabled; (refs.autoImportOptions || panel.querySelector("#ldb-auto-import-options")).style.display = autoImportEnabled ? "block" : "none"; const autoImportInterval = Storage.get(autoConfig.intervalKey, autoConfig.intervalDefault); const intervalSelect = refs.autoImportInterval || panel.querySelector("#ldb-auto-import-interval"); intervalSelect.value = autoImportInterval; // 如果存储的值不在选项中,回退到默认值 if (intervalSelect.selectedIndex === -1) { intervalSelect.value = autoConfig.intervalDefault; Storage.set(autoConfig.intervalKey, autoConfig.intervalDefault); } const linuxdoDedupMode = Utils.getLinuxDoImportDedupMode(); const linuxdoDedupSelect = refs.linuxdoDedupModeSelect || panel.querySelector("#ldb-linuxdo-dedup-mode"); linuxdoDedupSelect.value = linuxdoDedupMode; if (linuxdoDedupSelect.selectedIndex === -1) { linuxdoDedupSelect.value = CONFIG.DEFAULTS.linuxdoImportDedupMode; Storage.set(CONFIG.STORAGE_KEYS.LINUXDO_IMPORT_DEDUP_MODE, CONFIG.DEFAULTS.linuxdoImportDedupMode); } const bookmarkDedupMode = Utils.getBookmarkImportDedupMode(); const bookmarkDedupSelect = refs.bookmarkDedupModeSelect || panel.querySelector("#ldb-bookmark-dedup-mode"); bookmarkDedupSelect.value = bookmarkDedupMode; if (bookmarkDedupSelect.selectedIndex === -1) { bookmarkDedupSelect.value = CONFIG.DEFAULTS.bookmarkImportDedupMode; Storage.set(CONFIG.STORAGE_KEYS.BOOKMARK_IMPORT_DEDUP_MODE, CONFIG.DEFAULTS.bookmarkImportDedupMode); } (refs.aiCategoryAutoDedupCheckbox || panel.querySelector("#ldb-ai-category-auto-dedup")).checked = Storage.get( CONFIG.STORAGE_KEYS.AI_CATEGORY_AUTO_DEDUP, CONFIG.DEFAULTS.aiCategoryAutoDedup ); const updateAutoEnabled = Storage.get(CONFIG.STORAGE_KEYS.UPDATE_AUTO_CHECK_ENABLED, CONFIG.DEFAULTS.updateAutoCheckEnabled); const updateIntervalHours = Storage.get(CONFIG.STORAGE_KEYS.UPDATE_CHECK_INTERVAL_HOURS, CONFIG.DEFAULTS.updateCheckIntervalHours); const updateAutoEnabledEl = refs.updateAutoEnabled || panel.querySelector("#ldb-update-auto-enabled"); const updateAutoOptionsEl = refs.updateAutoOptions || panel.querySelector("#ldb-update-auto-options"); const updateIntervalEl = refs.updateIntervalHours || panel.querySelector("#ldb-update-interval-hours"); updateAutoEnabledEl.checked = updateAutoEnabled; updateAutoOptionsEl.style.display = updateAutoEnabled ? "block" : "none"; updateIntervalEl.value = String(updateIntervalHours); if (updateIntervalEl.selectedIndex === -1) { updateIntervalEl.value = String(CONFIG.DEFAULTS.updateCheckIntervalHours); Storage.set(CONFIG.STORAGE_KEYS.UPDATE_CHECK_INTERVAL_HOURS, CONFIG.DEFAULTS.updateCheckIntervalHours); } UpdateChecker.renderLastStatus(); }, renderSelfCheckResult: () => { const panel = UI.panel; if (!panel) return; const refs = UI.refs || {}; const resultEl = refs.selfCheckResult || panel.querySelector("#ldb-self-check-result"); if (!resultEl) return; const isUserscriptMode = typeof GM_info !== "undefined" && !!GM_info.scriptHandler; const hasBridgeMarker = BookmarkBridge.isExtensionAvailable(); const bookmarkSource = UI.getActiveBookmarkSource(); const hasGitHubUsername = !!Storage.get(CONFIG.STORAGE_KEYS.GITHUB_USERNAME, "").trim(); const hasGitHubToken = !!Storage.get(CONFIG.STORAGE_KEYS.GITHUB_TOKEN, "").trim(); const checks = [ { ok: true, label: "运行模式", value: isUserscriptMode ? "Userscript" : "Extension", }, { ok: hasBridgeMarker, label: "书签桥接", value: hasBridgeMarker ? "已检测" : "未检测", }, { ok: true, label: "当前来源", value: bookmarkSource === "github" ? "GitHub" : "Linux.do", }, { ok: hasGitHubUsername, label: "GitHub 用户名", value: hasGitHubUsername ? "已配置" : "未配置", }, { ok: hasGitHubToken, label: "GitHub Token", value: hasGitHubToken ? "已配置" : "未配置", }, ]; const tips = []; if (!hasBridgeMarker) { tips.push("• 未检测到书签桥接:请安装/启用 chrome-extension(Userscript)或确认扩展权限。"); } if (bookmarkSource === "github" && !hasGitHubUsername && !hasGitHubToken) { tips.push("• 当前来源为 GitHub:请至少配置 GitHub 用户名,建议同时配置 Token。"); } if (isUserscriptMode && hasBridgeMarker) { tips.push("• 当前为 Userscript + 桥接可用,建议仅保留一种运行模式避免混用。"); } if (tips.length === 0) { tips.push("• 当前自检通过:可直接执行加载与导入。", "• 如导入失败,请点击“复制诊断信息”并反馈。"); } const lines = [ ...checks.map(item => `${item.ok ? "✅" : "⚠️"} ${item.label}:${item.value}`), "", "建议:", ...tips, ]; resultEl.style.whiteSpace = "pre-line"; resultEl.textContent = lines.join("\n"); }, copyDiagnostics: async () => { const isUserscriptMode = typeof GM_info !== "undefined" && !!GM_info.scriptHandler; const hasBridgeMarker = BookmarkBridge.isExtensionAvailable(); const bookmarkSource = UI.getActiveBookmarkSource(); const hasGitHubUsername = !!Storage.get(CONFIG.STORAGE_KEYS.GITHUB_USERNAME, "").trim(); const hasGitHubToken = !!Storage.get(CONFIG.STORAGE_KEYS.GITHUB_TOKEN, "").trim(); const activeTab = Storage.get(CONFIG.STORAGE_KEYS.ACTIVE_TAB, "bookmarks"); const updateLastResultRaw = Storage.get(CONFIG.STORAGE_KEYS.UPDATE_LAST_RESULT, ""); const updateLastSeenVersion = Storage.get(CONFIG.STORAGE_KEYS.UPDATE_LAST_SEEN_VERSION, ""); const updateLastCheckAt = Storage.get(CONFIG.STORAGE_KEYS.UPDATE_LAST_CHECK_AT, ""); const modeConflictTipShown = Storage.get(CONFIG.STORAGE_KEYS.MODE_CONFLICT_TIP_SHOWN, false); const autoCfg = UI.getAutoImportConfigBySource(); const autoImportEnabled = Storage.get(autoCfg.enabledKey, autoCfg.enabledDefault); const autoImportInterval = Storage.get(autoCfg.intervalKey, autoCfg.intervalDefault); const issues = []; if (!hasBridgeMarker) { issues.push("missing_bookmark_bridge"); } if (bookmarkSource === "github" && !hasGitHubUsername && !hasGitHubToken) { issues.push("github_credentials_missing"); } let updateLastResult = ""; if (typeof updateLastResultRaw === "string") { updateLastResult = updateLastResultRaw; } else if (updateLastResultRaw && typeof updateLastResultRaw === "object") { try { updateLastResult = JSON.stringify(updateLastResultRaw); } catch { updateLastResult = String(updateLastResultRaw); } } const diagnostics = [ "[LD-Notion Diagnostics v2]", "", "[runtime]", `url=${location.href}`, `mode=${isUserscriptMode ? "userscript" : "extension"}`, `bridge=${hasBridgeMarker ? "ready" : "missing"}`, `source=${bookmarkSource}`, `active_tab=${activeTab}`, `bookmark_count=${Array.isArray(UI.bookmarks) ? UI.bookmarks.length : 0}`, "", "[config]", `github_username=${hasGitHubUsername ? "set" : "unset"}`, `github_token=${hasGitHubToken ? "set" : "unset"}`, `auto_import_enabled=${autoImportEnabled ? "true" : "false"}`, `auto_import_interval=${String(autoImportInterval)}`, `mode_conflict_tip_shown=${modeConflictTipShown ? "true" : "false"}`, "", "[update_checker]", `last_check_at=${updateLastCheckAt || ""}`, `last_seen_version=${updateLastSeenVersion || ""}`, `last_result=${(updateLastResult || "").slice(0, 500)}`, "", "[issues]", `count=${issues.length}`, `items=${issues.join(",")}`, "", "[env]", `user_agent=${navigator.userAgent}`, `time=${new Date().toISOString()}`, ].join("\n"); try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(diagnostics); } else { const textarea = document.createElement("textarea"); textarea.value = diagnostics; textarea.setAttribute("readonly", "readonly"); textarea.style.position = "fixed"; textarea.style.opacity = "0"; document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); textarea.remove(); } UI.showStatus("诊断信息已复制(v2)", "success"); } catch (error) { UI.showStatus(`复制失败: ${error.message || error}`, "error"); } }, // 显示状态 showStatus: (message, type = "info") => { const container = (UI.refs && UI.refs.statusContainer) || UI.panel.querySelector("#ldb-status-container"); container.innerHTML = ` ${message} × `; // 添加关闭按钮事件 const closeBtn = container.querySelector(".ldb-status-close"); if (closeBtn) { closeBtn.onclick = () => { container.innerHTML = ""; }; } // 错误消息延长显示时间(10秒),其他类型3秒 const timeout = type === "error" ? 10000 : 3000; setTimeout(() => { container.innerHTML = ""; }, timeout); }, // 显示进度 showProgress: (current, total, message) => { const container = (UI.refs && UI.refs.statusContainer) || UI.panel.querySelector("#ldb-status-container"); const percent = Math.round((current / total) * 100); container.innerHTML = ` ${current}/${total} (${percent}%) ${message} `; }, // 隐藏进度 hideProgress: () => { ((UI.refs && UI.refs.statusContainer) || UI.panel.querySelector("#ldb-status-container")).innerHTML = ""; }, // 更新 AI 模型选项 updateAIModelOptions: (service, customModels = null, preserveSelection = false) => { const refs = UI.refs || {}; const modelSelect = refs.aiModelSelect || UI.panel.querySelector("#ldb-ai-model"); const provider = AIService.PROVIDERS[service]; if (!provider || !modelSelect) return; const models = customModels || provider.models; const defaultModel = provider.defaultModel; // 保留当前选择的模型(如果需要且存在于新列表中) const currentValue = modelSelect.value; const shouldPreserve = preserveSelection && currentValue && models.includes(currentValue); modelSelect.innerHTML = models.map(model => { const isSelected = shouldPreserve ? model === currentValue : model === defaultModel; return `${model}`; }).join(""); }, // 更新工作区选择下拉框 updateWorkspaceSelect: (workspaceData) => { const refs = UI.refs || {}; const select = refs.workspaceSelect || UI.panel.querySelector("#ldb-workspace-select"); if (!select) return; const { databases = [], pages = [] } = workspaceData; const savedDatabaseId = Storage.get(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, ""); const savedPageId = Storage.get(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, ""); const exportTargetType = Storage.get(CONFIG.STORAGE_KEYS.EXPORT_TARGET_TYPE, CONFIG.DEFAULTS.exportTargetType); const restoreValue = exportTargetType === "page" ? (savedPageId ? `page:${savedPageId}` : "") : (savedDatabaseId ? `database:${savedDatabaseId}` : ""); let options = '-- 从工作区选择 --'; const knownValues = new Set(); // 数据库组 if (databases.length > 0) { options += ''; databases.forEach(db => { const value = `database:${db.id}`; knownValues.add(value); options += `📁 ${Utils.escapeHtml(db.title)}`; }); options += ''; } // 页面组(只显示工作区顶级页面) const workspacePages = pages.filter(p => p.parent === "workspace"); if (workspacePages.length > 0) { options += ''; workspacePages.forEach(page => { const value = `page:${page.id}`; knownValues.add(value); options += `📄 ${Utils.escapeHtml(page.title)}`; }); options += ''; } if (restoreValue && !knownValues.has(restoreValue)) { const shortId = restoreValue.split(":")[1] || ""; options += `已配置 (ID: ${shortId.slice(0, 8)}...)`; } select.innerHTML = options; if (restoreValue) { select.value = restoreValue; } }, // 更新 AI 查询目标数据库下拉框 updateAITargetDbOptions: (databases) => { const refs = UI.refs || {}; const select = refs.aiTargetDbSelect || UI.panel.querySelector("#ldb-ai-target-db"); if (!select) return; const savedValue = Storage.get(CONFIG.STORAGE_KEYS.AI_TARGET_DB, ""); // 保留固定选项,添加数据库列表 let options = '当前配置的数据库'; options += '所有工作区数据库'; if (databases.length > 0) { options += ''; databases.forEach(db => { options += `📁 ${Utils.escapeHtml(db.title)}`; }); options += ''; } select.innerHTML = options; // 恢复之前的选择 if (savedValue) { select.value = savedValue; } }, isGitHubMode: () => SiteDetector.isGitHub(), getActiveBookmarkSource: () => { const source = Storage.get(CONFIG.STORAGE_KEYS.BOOKMARK_SOURCE, CONFIG.DEFAULTS.bookmarkSource); return source === "github" ? "github" : "linuxdo"; }, isActiveGitHubSource: () => UI.getActiveBookmarkSource() === "github", getAutoImportConfigBySource: () => { const isGitHub = UI.isActiveGitHubSource(); return { isGitHub, enabledKey: isGitHub ? CONFIG.STORAGE_KEYS.GITHUB_AUTO_IMPORT_ENABLED : CONFIG.STORAGE_KEYS.AUTO_IMPORT_ENABLED, intervalKey: isGitHub ? CONFIG.STORAGE_KEYS.GITHUB_AUTO_IMPORT_INTERVAL : CONFIG.STORAGE_KEYS.AUTO_IMPORT_INTERVAL, enabledDefault: isGitHub ? CONFIG.DEFAULTS.githubAutoImportEnabled : CONFIG.DEFAULTS.autoImportEnabled, intervalDefault: isGitHub ? CONFIG.DEFAULTS.githubAutoImportInterval : CONFIG.DEFAULTS.autoImportInterval, }; }, applyBookmarkSourceUI: (source) => { const refs = UI.refs || {}; const isGitHub = source === "github"; if (refs.bookmarksLabel) { refs.bookmarksLabel.textContent = "已加载收藏数量"; } if (refs.autoImportLabel) { refs.autoImportLabel.textContent = "启用自动导入新收藏"; } if (refs.autoImportIntervalLabel) { refs.autoImportIntervalLabel.textContent = "轮询间隔"; } if (refs.sourceSelectLinuxdo) { refs.sourceSelectLinuxdo.classList.toggle("active", !isGitHub); } if (refs.sourceSelectGithub) { refs.sourceSelectGithub.classList.toggle("active", isGitHub); } const autoStatus = refs.autoImportStatus || UI.panel?.querySelector("#ldb-auto-import-status"); if (autoStatus && autoStatus.textContent && !autoStatus.textContent.includes("⚠️")) { autoStatus.textContent = ""; } }, getBookmarkKey: (bookmark) => { if (bookmark?.source === "github") { return `gh:${bookmark.sourceType}:${bookmark.itemKey}`; } return String(bookmark?.topic_id || bookmark?.bookmarkable_id || ""); }, isBookmarkKeyExported: (bookmarkKey) => { if (!bookmarkKey) return false; const dedupStrict = Utils.isLinuxDoDedupStrict(); if (!bookmarkKey.startsWith("gh:")) { if (!dedupStrict) return false; return Storage.isTopicExported(bookmarkKey); } const parts = bookmarkKey.split(":"); const sourceType = parts[1] || ""; const itemKey = parts.slice(2).join(":"); if (sourceType === "gists") { return GitHubAPI.isGistExported(itemKey); } return GitHubAPI.isExported(itemKey); }, isBookmarkExported: (bookmark) => { return UI.isBookmarkKeyExported(UI.getBookmarkKey(bookmark)); }, mapGitHubItemsToBookmarks: (items, sourceType) => { return (items || []).map((item) => { const isGist = sourceType === "gists"; const itemKey = isGist ? String(item.id || "") : String(item.full_name || item.name || ""); const title = isGist ? (item.description || Object.keys(item.files || {})[0] || `Gist ${item.id || ""}`) : (item.full_name || item.name || "未命名仓库"); return { source: "github", sourceType, itemKey, title, raw: item, }; }).filter(item => !!item.itemKey); }, exportGitHubSelected: async (selectedItems, settings, onProgress) => { const { apiKey, databaseId } = settings; if (!apiKey || !databaseId) { throw new Error("请先配置 Notion API Key 和数据库 ID"); } if (!selectedItems || selectedItems.length === 0) { return { success: [], failed: [], skipped: [] }; } const setupResult = await GitHubExporter.setupDatabaseProperties(databaseId, apiKey); if (!setupResult.success) { throw new Error(`数据库配置失败: ${setupResult.error}`); } const delay = Storage.get(CONFIG.STORAGE_KEYS.REQUEST_DELAY, CONFIG.DEFAULTS.requestDelay); const success = []; const failed = []; for (let i = 0; i < selectedItems.length; i++) { const item = selectedItems[i]; const bookmark = item.raw; const sourceType = item.sourceType; const label = item.title || item.itemKey; onProgress?.(i + 1, selectedItems.length, label); try { let properties; if (sourceType === "gists") { properties = GitHubExporter.buildGistProperties(bookmark); } else { const sourceMap = { stars: "Star", repos: "Repo", forks: "Fork" }; const enriched = await GitHubExporter.enrichRepo(bookmark, settings, { aiUsedCount: 0, aiMaxItems: 20 }); properties = GitHubExporter.buildRepoProperties(enriched, sourceMap[sourceType] || "Star"); } for (const key of Object.keys(properties)) { if (properties[key] === undefined) delete properties[key]; } await NotionAPI.request("POST", "/pages", { parent: { database_id: databaseId }, properties, }, apiKey); if (sourceType === "gists") { GitHubAPI.markGistExported(item.itemKey); } else { GitHubAPI.markExported(item.itemKey); } success.push({ title: item.title, url: bookmark?.html_url || "https://github.com", }); } catch (error) { console.warn(`[UI] GitHub 手动导出失败: ${item.itemKey}`, error); failed.push({ title: item.title, error: error.message, }); } if (i < selectedItems.length - 1 && delay > 0) { await Utils.sleep(delay); } } return { success, failed, skipped: [] }; }, // 渲染收藏列表 renderBookmarkList: () => { const list = (UI.refs && UI.refs.bookmarkList) || UI.panel.querySelector("#ldb-bookmark-list"); UI.recomputeExportStats(); if (!UI.bookmarks || UI.bookmarks.length === 0) { list.innerHTML = '暂无收藏'; UI.updateSelectCount(); return; } const githubMode = UI.isActiveGitHubSource(); list.innerHTML = UI.bookmarks.map((b) => { const bookmarkKey = UI.getBookmarkKey(b); const title = b.title || b.name || `帖子 ${bookmarkKey}`; const isExported = UI.isBookmarkKeyExported(bookmarkKey); const isSelected = UI.selectedBookmarks?.has(bookmarkKey); const sourceTag = githubMode ? `${(b.sourceType || "stars").toUpperCase()}` : ""; return ` ${Utils.truncateText(title, 35)} ${sourceTag}${isExported ? '已导出' : '待导出'} `; }).join(""); UI.updateSelectCount(); }, // 重算导出统计(在列表变更后调用) recomputeExportStats: () => { if (!UI.bookmarks || UI.bookmarks.length === 0) { UI.totalUnexportedCount = 0; UI.selectedUnexportedCount = 0; return; } let totalUnexported = 0; let selectedUnexported = 0; UI.bookmarks.forEach((b) => { const bookmarkKey = UI.getBookmarkKey(b); const isUnexported = !UI.isBookmarkKeyExported(bookmarkKey); if (isUnexported) { totalUnexported++; if (UI.selectedBookmarks?.has(bookmarkKey)) { selectedUnexported++; } } }); UI.totalUnexportedCount = totalUnexported; UI.selectedUnexportedCount = selectedUnexported; }, // 更新选中数量 updateSelectCount: () => { const count = UI.selectedBookmarks?.size || 0; const pendingCount = UI.selectedUnexportedCount || 0; ((UI.refs && UI.refs.selectCount) || UI.panel.querySelector("#ldb-select-count")).textContent = `已选 ${count} 个,待导出 ${Math.max(0, pendingCount)} 个`; // 更新全选框状态 const selectAll = (UI.refs && UI.refs.selectAll) || UI.panel.querySelector("#ldb-select-all"); if (count === 0) { selectAll.checked = false; selectAll.indeterminate = false; } else if (UI.totalUnexportedCount > 0 && pendingCount === UI.totalUnexportedCount) { selectAll.checked = true; selectAll.indeterminate = false; } else { selectAll.checked = false; selectAll.indeterminate = true; } }, // 显示导出报告 showReport: (results) => { const container = (UI.refs && UI.refs.reportContainer) || UI.panel.querySelector("#ldb-report-container"); const { success, failed, skipped } = results; let html = ''; html += '📊 导出报告'; if (success.length > 0) { html += ''; html += `✅ 成功 (${success.length})`; success.slice(0, 10).forEach(item => { html += ` ✓ ${Utils.truncateText(item.title, 40)} `; }); if (success.length > 10) { html += `... 还有 ${success.length - 10} 个`; } html += ''; } if (failed.length > 0) { html += ''; html += `❌ 失败 (${failed.length})`; failed.forEach(item => { html += ` ✗ ${Utils.truncateText(item.title, 35)} `; html += `${item.error}`; }); html += ''; } if (skipped && skipped.length > 0) { html += ''; html += `⏭️ 已跳过 (${skipped.length})`; html += ` 由于取消操作,${skipped.length} 个收藏未导出 `; html += ''; } html += ''; container.innerHTML = html; }, // 更新操作日志面板 updateLogPanel: () => { if (!UI.panel) return; const listContainer = UI.panel.querySelector("#ldb-log-list"); const countBadge = UI.panel.querySelector("#ldb-log-count"); if (!listContainer || !countBadge) return; const logs = OperationLog.getRecent(20); countBadge.textContent = logs.length; if (logs.length === 0) { listContainer.innerHTML = '暂无操作记录'; return; } let html = ''; logs.forEach(entry => { const formatted = OperationLog.formatEntry(entry); html += ` ${formatted.statusIcon} ${formatted.operation} ${formatted.time} · ${formatted.duration} ${formatted.error ? `${formatted.error}` : ''} `; }); listContainer.innerHTML = html; }, // 拖拽功能 makeDraggable: (element, handle) => { let offsetX, offsetY, isDragging = false; handle.onmousedown = (e) => { if (e.target.tagName === "BUTTON") return; isDragging = true; offsetX = e.clientX - element.offsetLeft; offsetY = e.clientY - element.offsetTop; document.body.style.userSelect = "none"; }; document.onmousemove = (e) => { if (!isDragging) return; const x = Math.max(0, Math.min(window.innerWidth - element.offsetWidth, e.clientX - offsetX)); const y = Math.max(0, Math.min(window.innerHeight - element.offsetHeight, e.clientY - offsetY)); element.style.left = x + "px"; element.style.top = y + "px"; element.style.right = "auto"; }; document.onmouseup = () => { isDragging = false; document.body.style.userSelect = ""; }; }, maybePromptBookmarkExtensionInstall: () => { const isUserscriptMode = typeof GM_info !== "undefined" && !!GM_info.scriptHandler; if (!isUserscriptMode) return; if (BookmarkBridge.isExtensionAvailable()) return; if (Storage.get(CONFIG.STORAGE_KEYS.EXT_INSTALL_PROMPT_SHOWN, false)) return; Storage.set(CONFIG.STORAGE_KEYS.EXT_INSTALL_PROMPT_SHOWN, true); const shouldInstallNow = window.confirm("检测到你尚未安装书签桥接扩展。\n\n是否现在打开安装页面?"); if (shouldInstallNow) { InstallHelper.openBookmarkExtensionInstall(); } }, // 初始化 init: () => { UI.injectStyles(); UI.createPanel(); UI.miniBtn = UI.createMiniButton(); // 面板可拉伸(左边+上边+下边+左上角+左下角) PanelResize.makeResizable(UI.panel, { edges: ["l", "t", "b", "tl", "bl"], storageKey: CONFIG.STORAGE_KEYS.PANEL_SIZE_MAIN, minWidth: 300, minHeight: 300, }); // 检查是否需要最小化启动 if (Storage.get(CONFIG.STORAGE_KEYS.PANEL_MINIMIZED, false)) { UI.panel.style.display = "none"; UI.miniBtn.style.display = "flex"; } UI.maybePromptBookmarkExtensionInstall(); }, }; // =========================================== // 通用网页剪藏 UI // =========================================== const GenericUI = { panel: null, floatBtn: null, isExporting: false, // 注入样式 injectStyles: () => { DesignSystem.ensureBase(); StyleManager.injectOnce(DesignSystem.STYLE_IDS.GENERIC, ` /* LDB_UI_GENERIC */ .gclip-float-btn { position: fixed; bottom: 24px; right: 24px; width: 48px; height: 48px; border-radius: 999px; background: linear-gradient(135deg, var(--ldb-ui-accent) 0%, var(--ldb-ui-accent-2) 100%); color: #fff; border: 1px solid rgba(37, 99, 235, 0.35); cursor: pointer; box-shadow: var(--ldb-ui-shadow-sm); z-index: 2147483647; display: flex; align-items: center; justify-content: center; font-size: 22px; transition: transform 0.18s ease, box-shadow 0.18s ease; user-select: none; } .gclip-float-btn:hover { transform: translateY(-1px) scale(1.03); box-shadow: var(--ldb-ui-shadow); } .gclip-float-btn.exporting { background: linear-gradient(135deg, #f59e0b, var(--ldb-ui-warning)); border-color: rgba(217, 119, 6, 0.35); animation: gclip-pulse 1.2s infinite; } .gclip-float-btn.success { background: linear-gradient(135deg, #10b981, var(--ldb-ui-success)); border-color: rgba(22, 163, 74, 0.35); } .gclip-float-btn.error { background: linear-gradient(135deg, #ef4444, var(--ldb-ui-danger)); border-color: rgba(220, 38, 38, 0.35); } @keyframes gclip-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } .gclip-panel { position: fixed; bottom: 84px; right: 24px; width: 320px; z-index: 2147483646; display: none; overflow: hidden; transform: translateY(12px); opacity: 0; transition: transform 0.22s ease, opacity 0.22s ease; } .gclip-panel.visible { display: block; transform: translateY(0); opacity: 1; } .gclip-panel-header { background: linear-gradient(135deg, var(--ldb-ui-accent) 0%, var(--ldb-ui-accent-2) 100%); color: #fff; } .gclip-panel-header .close-btn { border-color: rgba(255, 255, 255, 0.22); background: rgba(255, 255, 255, 0.14); color: #fff; } .gclip-panel-header .close-btn:hover { background: rgba(255, 255, 255, 0.22); } .gclip-preview { border: 1px solid var(--ldb-ui-border); border-radius: 12px; padding: 10px 12px; background: rgba(148, 163, 184, 0.08); margin-bottom: 12px; } .gclip-preview .title { font-size: 13px; font-weight: 700; line-height: 1.45; color: var(--ldb-ui-text); } .gclip-preview .meta { margin-top: 4px; font-size: 12px; color: var(--ldb-ui-muted); } .gclip-status { margin-top: 10px; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--ldb-ui-border); font-size: 12px; display: none; } .gclip-status.info { display: block; border-color: rgba(37, 99, 235, 0.30); background: rgba(37, 99, 235, 0.10); color: var(--ldb-ui-text); } .gclip-status.success { display: block; border-color: rgba(22, 163, 74, 0.35); background: rgba(22, 163, 74, 0.12); color: var(--ldb-ui-text); } .gclip-status.error { display: block; border-color: rgba(220, 38, 38, 0.35); background: rgba(220, 38, 38, 0.12); color: var(--ldb-ui-text); } .gclip-btn-primary { /* alias for .gclip-btn */ } .gclip-btn-setup { border: 1px solid var(--ldb-ui-border); background: rgba(148, 163, 184, 0.12); color: var(--ldb-ui-text); font-weight: 650; } `); }, // 创建浮动按钮 createFloatButton: () => { const btn = document.createElement("button"); btn.className = "gclip-float-btn"; btn.setAttribute("data-ldb-root", ""); btn.innerHTML = "📎"; btn.title = "导出到 Notion"; btn.addEventListener("click", () => { if (GenericUI.isExporting) return; GenericUI.togglePanel(); }); document.body.appendChild(btn); GenericUI.floatBtn = btn; return btn; }, // 创建设置面板 createPanel: () => { const panel = document.createElement("div"); panel.className = "gclip-panel"; panel.setAttribute("data-ldb-root", ""); const apiKey = Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, ""); const dbId = Storage.get(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, ""); const parentPageId = Storage.get(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, ""); const exportType = Storage.get(CONFIG.STORAGE_KEYS.EXPORT_TARGET_TYPE, "database"); const imgMode = Storage.get(CONFIG.STORAGE_KEYS.IMG_MODE, "external"); const meta = GenericExtractor.extractMeta(); // 根据导出类型判断是否已配置完成 const targetId = exportType === "page" ? parentPageId : dbId; const isConfigured = !!(apiKey && targetId); panel.innerHTML = ` 📎 导出到 Notion ✕ ${Utils.escapeHtml(meta.title)} ${meta.author ? `作者: ${Utils.escapeHtml(meta.author)}` : ""} ${meta.siteName ? `来源: ${Utils.escapeHtml(meta.siteName)}` : ""} ${meta.publishDate ? ` · ${meta.publishDate}` : ""} Notion API Key 保存 导出目标类型 数据库 页面(子页面) ${exportType === "page" ? "父页面" : "数据库"} 未选择 刷新 优先从工作区列表选择,失败时可手动输入 ID 手动输入 ID(高级) 高级:手动输入 ID 图片处理 外链引用 上传到 Notion 跳过图片 保存配置 导出当前页面 修改配置 `; document.body.appendChild(panel); GenericUI.panel = panel; // 绑定事件 GenericUI.bindEvents(); // 初始化导出目标 UI panel.querySelector("#gclip-export-type").value = exportType; panel.querySelector("#gclip-target-label").textContent = exportType === "page" ? "父页面" : "数据库"; panel.querySelector("#gclip-target-id").value = targetId; const apiKeyForInit = Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, ""); GenericUI.loadTargetOptionsFromCache(apiKeyForInit); if (apiKeyForInit) { GenericUI.refreshWorkspaceTargets(apiKeyForInit, true); } return panel; }, updateTargetSelectOptions: (databases = [], pages = []) => { const panel = GenericUI.panel; if (!panel) return; const select = panel.querySelector("#gclip-target-select"); const exportType = panel.querySelector("#gclip-export-type")?.value || "database"; if (!select) return; const savedDatabaseId = Storage.get(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, ""); const savedPageId = Storage.get(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, ""); const restoreValue = exportType === "page" ? (savedPageId ? `page:${savedPageId}` : "") : savedDatabaseId; let options = '未选择'; const known = new Set(); if (exportType === "page") { const workspacePages = pages.filter(p => p.parent === "workspace"); workspacePages.forEach(page => { const value = `page:${page.id}`; known.add(value); options += `📄 ${Utils.escapeHtml(page.title || "未命名页面")}`; }); } else { databases.forEach(db => { known.add(db.id); options += `📁 ${Utils.escapeHtml(db.title || "未命名数据库")}`; }); } if (restoreValue && !known.has(restoreValue)) { const shortId = restoreValue.replace(/^page:/, ""); options += `已配置 (ID: ${shortId.slice(0, 8)}...)`; } select.innerHTML = options; if (restoreValue) { select.value = restoreValue; } }, refreshWorkspaceTargets: async (apiKey, silent = false) => { const panel = GenericUI.panel; if (!panel) return; const refreshBtn = panel.querySelector("#gclip-refresh-workspace"); const tip = panel.querySelector("#gclip-target-tip"); if (!apiKey) { if (!silent) GenericUI.showStatus("请先设置 Notion API Key", "error"); return; } if (refreshBtn) { refreshBtn.disabled = true; refreshBtn.textContent = "刷新中"; } if (tip) { tip.textContent = "正在获取数据库列表..."; } try { const workspace = await WorkspaceService.fetchWorkspaceStaged(apiKey, { includePages: true, onProgress: (progress) => { if (!tip) return; if (progress.phase === "databases") { tip.textContent = `正在获取数据库列表... 已加载 ${progress.loaded} 个`; } else if (progress.phase === "pages") { tip.textContent = `数据库已就绪,正在获取页面... 已加载 ${progress.loaded} 个`; } }, onPhaseComplete: (phase, partialWorkspace) => { const workspaceData = { apiKeyHash: apiKey.slice(-8), databases: partialWorkspace.databases || [], pages: partialWorkspace.pages || [], timestamp: Date.now(), }; Storage.set(CONFIG.STORAGE_KEYS.WORKSPACE_PAGES, JSON.stringify(workspaceData)); GenericUI.updateTargetSelectOptions(workspaceData.databases, workspaceData.pages); if (tip && phase === "databases") { tip.textContent = `✅ 已加载 ${workspaceData.databases.length} 个数据库,可先选择目标;页面列表继续加载中...`; } }, }); const workspaceData = { apiKeyHash: apiKey.slice(-8), databases: workspace.databases, pages: workspace.pages, timestamp: Date.now(), }; Storage.set(CONFIG.STORAGE_KEYS.WORKSPACE_PAGES, JSON.stringify(workspaceData)); GenericUI.updateTargetSelectOptions(workspace.databases, workspace.pages); if (tip) { tip.textContent = `已加载 ${workspace.databases.length} 个数据库,${workspace.pages.filter(p => p.parent === "workspace").length} 个页面`; } } catch (error) { if (tip) { tip.textContent = `加载失败:${error.message}`; } } finally { if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = "刷新"; } } }, loadTargetOptionsFromCache: (apiKey) => { const panel = GenericUI.panel; if (!panel) return; const tip = panel.querySelector("#gclip-target-tip"); let databases = []; let pages = []; const raw = Storage.get(CONFIG.STORAGE_KEYS.WORKSPACE_PAGES, "{}"); try { const wsData = JSON.parse(raw); const keyHash = apiKey ? apiKey.slice(-8) : ""; const cacheValid = !apiKey || !wsData.apiKeyHash || wsData.apiKeyHash === keyHash; if (cacheValid) { databases = wsData.databases || []; pages = wsData.pages || []; } } catch {} GenericUI.updateTargetSelectOptions(databases, pages); if (tip) { if (databases.length > 0 || pages.length > 0) { tip.textContent = "已加载缓存工作区列表,可点击刷新更新"; } else { tip.textContent = "优先从工作区列表选择,失败时可手动输入 ID"; } } }, // 绑定面板事件 bindEvents: () => { const panel = GenericUI.panel; // 关闭按钮 panel.querySelector("#gclip-close").addEventListener("click", () => { GenericUI.togglePanel(false); }); // 导出类型切换 panel.querySelector("#gclip-export-type").addEventListener("change", () => { const isPage = panel.querySelector("#gclip-export-type").value === "page"; panel.querySelector("#gclip-target-label").textContent = isPage ? "父页面" : "数据库"; GenericUI.loadTargetOptionsFromCache(Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, "")); }); // 刷新工作区目标列表 panel.querySelector("#gclip-refresh-workspace").addEventListener("click", async () => { const keyInput = panel.querySelector("#gclip-api-key-input").value.trim(); const apiKey = keyInput || Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, ""); await GenericUI.refreshWorkspaceTargets(apiKey); }); // 手动输入开关 panel.querySelector("#gclip-toggle-manual-target").addEventListener("click", () => { const wrap = panel.querySelector("#gclip-manual-target-wrap"); const visible = wrap.style.display !== "none"; wrap.style.display = visible ? "none" : "block"; }); // 保存 API Key(从面板内密码输入框读取,避免使用可被宿主页面拦截的 prompt()) panel.querySelector("#gclip-save-api-key").addEventListener("click", () => { const key = panel.querySelector("#gclip-api-key-input").value.trim(); if (key) { Storage.set(CONFIG.STORAGE_KEYS.NOTION_API_KEY, key); panel.querySelector("#gclip-api-key-input").value = ""; panel.querySelector("#gclip-api-key-input").placeholder = "已配置 (点击保存可更新)"; GenericUI.showStatus("API Key 已保存", "success"); } else { GenericUI.showStatus("请输入 API Key", "error"); } }); // 保存配置 panel.querySelector("#gclip-save-settings").addEventListener("click", async () => { // 仅当用户主动输入了新 key 时才更新(不从 DOM 预填,防止泄漏) const liveKey = panel.querySelector("#gclip-api-key-input").value.trim(); if (liveKey) { Storage.set(CONFIG.STORAGE_KEYS.NOTION_API_KEY, liveKey); panel.querySelector("#gclip-api-key-input").value = ""; panel.querySelector("#gclip-api-key-input").placeholder = "已配置 (点击保存可更新)"; } const apiKey = Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY); const exportType = panel.querySelector("#gclip-export-type").value; const selectValue = panel.querySelector("#gclip-target-select")?.value || ""; const manualTargetId = panel.querySelector("#gclip-target-id").value.trim().replace(/-/g, ""); const selectedTargetId = selectValue.startsWith("page:") ? selectValue.slice(5) : selectValue; const targetId = (selectedTargetId || manualTargetId).replace(/-/g, ""); const imgMode = panel.querySelector("#gclip-img-mode").value; if (!apiKey) return GenericUI.showStatus("请先设置 Notion API Key", "error"); if (!targetId) return GenericUI.showStatus("请先选择目标,或手动输入 ID", "error"); Storage.set(CONFIG.STORAGE_KEYS.EXPORT_TARGET_TYPE, exportType); Storage.set(CONFIG.STORAGE_KEYS.IMG_MODE, imgMode); if (exportType === "page") { Storage.set(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, targetId); } else { Storage.set(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, targetId); // 自动设置数据库属性 GenericUI.showStatus("正在配置数据库属性...", "info"); const result = await GenericExporter.setupDatabaseProperties(targetId, apiKey); if (!result.success) { return GenericUI.showStatus(`配置失败: ${result.message || result.error}`, "error"); } } GenericUI.loadTargetOptionsFromCache(apiKey); GenericUI.showStatus("配置已保存", "success"); panel.querySelector("#gclip-settings").style.display = "none"; panel.querySelector("#gclip-export").style.display = "block"; panel.querySelector("#gclip-show-settings").style.display = "block"; }); // 显示设置(不在 DOM 中预填 API Key,防止第三方页面读取) panel.querySelector("#gclip-show-settings").addEventListener("click", () => { const settings = panel.querySelector("#gclip-settings"); const showing = settings.style.display === "none"; if (showing) { const exportType = Storage.get(CONFIG.STORAGE_KEYS.EXPORT_TARGET_TYPE, "database"); panel.querySelector("#gclip-export-type").value = exportType; panel.querySelector("#gclip-target-label").textContent = exportType === "page" ? "父页面" : "数据库"; const tid = exportType === "page" ? Storage.get(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, "") : Storage.get(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, ""); panel.querySelector("#gclip-target-id").value = tid; const apiKey = Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, ""); GenericUI.loadTargetOptionsFromCache(apiKey); if (apiKey) { GenericUI.refreshWorkspaceTargets(apiKey, true); } // API Key 不预填到 DOM,用户需手动输入或留空使用已保存配置 } settings.style.display = showing ? "block" : "none"; }); // 导出按钮 panel.querySelector("#gclip-export").addEventListener("click", () => { GenericUI.doExport(); }); }, // 执行导出 doExport: async () => { if (GenericUI.isExporting) return; GenericUI.isExporting = true; const btn = GenericUI.panel.querySelector("#gclip-export"); const floatBtn = GenericUI.floatBtn; btn.disabled = true; btn.textContent = "导出中..."; floatBtn.className = "gclip-float-btn exporting"; GenericUI.showStatus("正在提取页面内容...", "info"); try { const apiKey = Storage.get(CONFIG.STORAGE_KEYS.NOTION_API_KEY, ""); const exportType = Storage.get(CONFIG.STORAGE_KEYS.EXPORT_TARGET_TYPE, "database"); const imgMode = Storage.get(CONFIG.STORAGE_KEYS.IMG_MODE, "external"); const settings = { apiKey, exportTargetType: exportType, databaseId: Storage.get(CONFIG.STORAGE_KEYS.NOTION_DATABASE_ID, ""), parentPageId: Storage.get(CONFIG.STORAGE_KEYS.PARENT_PAGE_ID, ""), imgMode, }; GenericUI.showStatus("正在导出到 Notion...", "info"); const { page, meta } = await GenericExporter.exportCurrentPage(settings); floatBtn.className = "gclip-float-btn success"; GenericUI.showStatus(`导出成功: ${meta.title}`, "success"); // 3 秒后恢复按钮状态 setTimeout(() => { floatBtn.className = "gclip-float-btn"; }, 3000); } catch (error) { floatBtn.className = "gclip-float-btn error"; GenericUI.showStatus(`导出失败: ${error.message}`, "error"); setTimeout(() => { floatBtn.className = "gclip-float-btn"; }, 3000); } finally { GenericUI.isExporting = false; btn.disabled = false; btn.textContent = "导出当前页面"; } }, // 显示状态 showStatus: (message, type = "info") => { const el = GenericUI.panel.querySelector("#gclip-status"); el.textContent = message; el.className = `gclip-status ${type}`; }, // 切换面板显示 togglePanel: (show) => { if (!GenericUI.panel) return; const isVisible = GenericUI.panel.classList.contains("visible"); const shouldShow = show !== undefined ? show : !isVisible; if (shouldShow) { GenericUI.panel.style.display = "block"; // 触发 reflow 使 transition 生效 GenericUI.panel.offsetHeight; GenericUI.panel.classList.add("visible"); } else { GenericUI.panel.classList.remove("visible"); GenericUI.panel.addEventListener("transitionend", function handler() { if (!GenericUI.panel.classList.contains("visible")) { GenericUI.panel.style.display = "none"; } GenericUI.panel.removeEventListener("transitionend", handler); }); } }, // 初始化 init: () => { // 非 HTML 文档(如 XML/RSS)无 body,跳过 UI 注入 if (!document.body) return; GenericUI.injectStyles(); GenericUI.createFloatButton(); GenericUI.createPanel(); // 面板可拉伸(左边+上边+左上角) PanelResize.makeResizable(GenericUI.panel, { edges: ["l", "t", "tl"], storageKey: CONFIG.STORAGE_KEYS.PANEL_SIZE_GENERIC, minWidth: 260, minHeight: 200, }); }, }; // =========================================== // 入口 // =========================================== function main() { const initUI = () => { // 初始化主题系统 DesignSystem.initTheme(); const currentSite = SiteDetector.detect(); if (currentSite === SiteDetector.SITES.LINUX_DO) { // 所有 Linux.do 页面均显示面板(导出/AI 助手/设置) UI.init(); UpdateChecker.init(); // 非收藏页面额外启动后台自动导入 const isBookmarkPage = /\/u\/[^/]+\/activity\/bookmarks/.test(window.location.pathname); if (!isBookmarkPage) { AutoImporter.init(); } } else if (currentSite === SiteDetector.SITES.NOTION) { // Notion 站点:初始化浮动 AI 助手 NotionSiteUI.init(); } else if (currentSite === SiteDetector.SITES.GITHUB) { // GitHub 站点:使用与 Linux.do 同步的完整面板 UI.init(); UpdateChecker.init(); GitHubAutoImporter.init(); } else if (currentSite === SiteDetector.SITES.GENERIC) { // 通用网页:初始化剪藏按钮 GenericUI.init(); } }; // 等待页面加载完成 if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initUI); } else { initUI(); } } main(); })();