// ==UserScript== // @name DeepSeek Chat Folder Manager // @namespace https://github.com/Azurboy/deepseek-voyager // @version 0.2.6 // @description DeepSeek Chat 侧边栏文件夹管理 // @author DeepSeek Voyager (adapted) // @match https://chat.deepseek.com/* // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @run-at document-idle // ==/UserScript== /* ═══════════════════════════════════════════ 配置 & 常量 ═══════════════════════════════════════════ */ const ROOT_ID = '__root_conversations__'; const DATA_KEY = 'dsFolderData'; const MAX_DEPTH = 2; const SEL = { sidebar: '.ds-scroll-area', convItem: 'a[href*="/a/chat/s/"]', convTitle: 'a[href*="/a/chat/s/"] div', }; const I18N = { folder_title: '文件夹', folder_create: '新建文件夹', folder_rename: '重命名', folder_delete: '删除文件夹', folder_create_subfolder: '新建子文件夹', folder_import: '导入', folder_export: '导出', folder_import_title: '导入文件夹配置', folder_import_merge: '合并(保留现有)', folder_import_overwrite: '覆盖(清空现有)', folder_import_select_file: '选择 JSON 文件', folder_import_invalid: '文件格式不正确', folder_import_success: '导入成功:{folders} 个文件夹,{conversations} 个对话', folder_import_error: '导入失败:{error}', folder_import_confirm_overwrite: '覆盖将清空当前数据,是否继续?', folder_export_success: '导出成功', folder_name_prompt: '文件夹名称', folder_delete_confirm: '确定删除该文件夹及所有子项?', folder_remove_conv: '从文件夹中移除', folder_remove_conv_confirm: '确定将"{title}"移出该文件夹?', conv_move_to: '移动到文件夹…', conv_move_to_title: '移动到文件夹', btn_cancel: '取消', btn_save: '保存', btn_delete: '删除', btn_import: '导入', folder_depth_limit: '最多只能嵌套两层子文件夹', folder_move_depth_limit: '移动后嵌套层数将超过限制', folder_toggle_collapse: '折叠/展开文件夹', }; const t = (key, vars) => Object.entries(vars || {}).reduce( (s, [k, v]) => s.replace(`{${k}}`, v), I18N[key] || key ); /* ═══════════════════════════════════════════ 图标定义(Material Design 风格,黑色主题) ═══════════════════════════════════════════ */ const ICONS = { upload: { d: 'M444-336v-363L321-576l-51-51 210-210 210 210-51 51-123-123v363h-72ZM264-192q-29.7 0-50.85-21.15Q192-234.3 192-264v-120h72v120h432v-120h72v120q0 29.7-21.15 50.85Q725.7-192 696-192H264Z', vb: '0 -960 960 960' }, download: { d: 'M480-336 270-546l51-51 123 123v-363h72v363l123-123 51 51-210 210ZM264-192q-29.7 0-50.85-21.15Q192-234.3 192-264v-120h72v120h432v-120h72v120q0 29.7-21.15 50.85Q725.7-192 696-192H264Z', vb: '0 -960 960 960' }, add: { d: 'M444-444H240v-72h204v-204h72v204h204v72H516v204h-72v-204Z', vb: '0 -960 960 960' }, expand_more: { d: 'M480-360 276-564l51-51 153 153 153-153 51 51-204 204Z', vb: '0 -960 960 960' }, chevron_right: { d: 'M534-480 354-660l51-51 231 231-231 231-51-51 180-180Z', vb: '0 -960 960 960' }, folder: { d: 'M168-192q-29.7 0-50.85-21.15Q96-234.3 96-264v-432q0-29.7 21.15-50.85Q138.3-768 168-768h216l96 96h312q29.7 0 50.85 21.15Q864-629.7 864-600v336q0 29.7-21.15 50.85Q821.7-192 792-192H168Zm0-72h624v-336H450l-96-96H168v432Zm0 0v-432 432Z', vb: '0 -960 960 960' }, folder_open: { d: 'M168-192q-29 0-50.5-21.5T96-264v-432q0-30 21.5-51t50.5-21h216l96 96h312q30 0 51 21t21 51H450l-96-96H168v432l102-312h618L761-219q-8 24-29 37.5T684-168H168Zm84-72h438l90-288H342l-90 288Zm0 0 90-288-90 288Zm-84-360v-72 72Z', vb: '0 -960 960 960' }, bookshelf: { d: 'M240-240v-480h120v480H240Zm180 0v-480h120v480H420Zm180 0v-480h120v480H600ZM120-120v-72h720v72H120Zm0-648v-72h720v72H120Z', vb: '0 -960 960 960' }, more_vert: { d: 'M480-192q-29.7 0-50.85-21.15Q408-234.3 408-264q0-29.7 21.15-50.85Q450.3-336 480-336q29.7 0 50.85 21.15Q552-293.7 552-264q0 29.7-21.15 50.85Q509.7-192 480-192Zm0-216q-29.7 0-50.85-21.15Q408-450.3 408-480q0-29.7 21.15-50.85Q450.3-552 480-552q29.7 0 50.85 21.15Q552-509.7 552-480q0 29.7-21.15 50.85Q509.7-408 480-408Zm0-216q-29.7 0-50.85-21.15Q408-666.3 408-696q0-29.7 21.15-50.85Q450.3-768 480-768q29.7 0 50.85 21.15Q552-725.7 552-696q0 29.7-21.15 50.85Q509.7-624 480-624Z', vb: '0 -960 960 960' }, chat_bubble: { d: 'M240-400h320v-80H240v80Zm0-120h480v-80H240v80Zm0-120h480v-80H240v80ZM80-80v-740q0-24 18-42t42-18h680q24 0 42 18t18 42v520q0 24-18 42t-42 18H240L80-80Z', vb: '0 -960 960 960' }, close: { d: 'm291-240-51-51 189-189-189-189 51-51 189 189 189-189 51 51-189 189 189 189-51 51-189-189-189 189Z', vb: '0 -960 960 960' }, check: { d: 'm382-354 339-339 51 51-390 390-195-195 51-51 144 144Z', vb: '0 -960 960 960' }, }; /* ═══════════════════════════════════════════ 工具函数 ═══════════════════════════════════════════ */ const uid = () => `f_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; const store = { get: k => { try { return GM_getValue(k, null); } catch (e) { console.warn('[FolderManager] GM_getValue fallback:', e); return localStorage.getItem(k); } }, set: (k, v) => { try { GM_setValue(k, v); } catch (e) { console.warn('[FolderManager] GM_setValue fallback:', e); localStorage.setItem(k, v); } }, }; const $ = (s, p = document) => p.querySelector(s); const $$ = (s, p = document) => [...p.querySelectorAll(s)]; const el = (tag, cls, ...children) => { const e = document.createElement(tag); if (cls) e.className = cls; children.forEach(c => { if (c) e.append(typeof c === 'string' ? document.createTextNode(c) : c); }); return e; }; const mkIcon = (name, sz = 20) => { const cfg = ICONS[name]; if (!cfg) { console.error(`[FolderManager] Unknown icon: ${name}`); return el('span'); } const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); s.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); s.setAttribute('viewBox', cfg.vb); s.setAttribute('width', sz); s.setAttribute('height', sz); if (cfg.stroke) { s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '2'); s.setAttribute('stroke-linecap', 'round'); s.setAttribute('stroke-linejoin', 'round'); } else { s.setAttribute('fill', 'currentColor'); } s.innerHTML = ``; return s; }; /* ═══════════════════════════════════════════ FolderManager 核心类 ═══════════════════════════════════════════ */ class FolderManager { constructor() { this.data = { folders: [], folderContents: {} }; this.containerEl = null; this.sidebarEl = null; this._lastConvInfo = null; this._importing = false; this._exporting = false; this._tooltip = null; this._collapsed = false; } load() { try { const r = store.get(DATA_KEY); if (r) this.data = JSON.parse(r); } catch (e) { console.error('[FolderManager] Load data error:', e); } } save() { try { store.set(DATA_KEY, JSON.stringify(this.data)); } catch (e) { console.error('[FolderManager] Save data error:', e); } } folder(id) { return this.data.folders.find(f => f.id === id); } children(parentId) { return this.data.folders.filter(f => f.parentId === parentId); } descendants(id) { const r = [id]; this.children(id).forEach(c => r.push(...this.descendants(c.id))); return r; } sorted(folders) { return [...folders].sort((a, b) => { const orderA = a.order !== undefined ? a.order : Infinity; const orderB = b.order !== undefined ? b.order : Infinity; if (orderA !== orderB) return orderA - orderB; return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); }); } isDescendant(folderId, targetId) { let cur = folderId; while (cur) { if (cur === targetId) return true; cur = this.folder(cur)?.parentId || null; } return false; } getFolderDepth(folderId) { let depth = 0; let current = this.folder(folderId); while (current && current.parentId !== null) { depth++; current = this.folder(current.parentId); } return depth; } getSubtreeMaxDepth(folderId) { const subFolders = this.children(folderId); if (subFolders.length === 0) return 0; let maxDepth = 0; for (const sub of subFolders) { const subDepth = 1 + this.getSubtreeMaxDepth(sub.id); if (subDepth > maxDepth) maxDepth = subDepth; } return maxDepth; } createFolder(parentId = null) { if (parentId !== null) { const depth = this.getFolderDepth(parentId); if (depth >= MAX_DEPTH) { this._notify(t('folder_depth_limit'), 'error'); return; } } if (this._collapsed) { this._collapsed = false; this.containerEl?.classList.remove('gv-collapsed'); } const wrap = this._inlineInput(t('folder_name_prompt'), name => { if (!name) return; const siblings = this.children(parentId); const maxOrder = siblings.reduce((max, f) => Math.max(max, f.order ?? -1), -1); const f = { id: uid(), name, parentId, isExpanded: true, createdAt: Date.now(), updatedAt: Date.now(), order: maxOrder + 1 }; this.data.folders.push(f); this.data.folderContents[f.id] = []; this.save(); this.refresh(); }); const list = $('.gv-folder-list', this.containerEl); if (!list) return; if (parentId) { const parentEl = $(`[data-folder-id="${parentId}"]`, list); const content = parentEl?.querySelector('.gv-folder-content'); (content || list).insertBefore(wrap, (content || list).firstChild); } else { list.insertBefore(wrap, list.firstChild); } } renameFolder(id) { const f = this.folder(id); if (!f) return; const nameEl = $(`[data-folder-id="${id}"] .gv-folder-name`, this.containerEl); if (!nameEl) return; nameEl.classList.add('gv-hidden'); const wrap = this._inlineInput(t('folder_rename'), name => { if (name) { f.name = name; f.updatedAt = Date.now(); this.save(); } this.refresh(); }, f.name); nameEl.parentElement.insertBefore(wrap, nameEl.nextSibling); } deleteFolder(id) { const refEl = $(`[data-folder-id="${id}"]`, this.containerEl); this._confirm(t('folder_delete_confirm'), () => { const ids = this.descendants(id); this.data.folders = this.data.folders.filter(f => !ids.includes(f.id)); ids.forEach(i => delete this.data.folderContents[i]); this.save(); this.refresh(); }, refEl); } toggleExpand(id) { const f = this.folder(id); if (!f) return; f.isExpanded = !f.isExpanded; f.updatedAt = Date.now(); this.save(); this.refresh(); } toggleCollapse() { this._collapsed = !this._collapsed; if (this.containerEl) { this.containerEl.classList.toggle('gv-collapsed', this._collapsed); } } addConv(folderId, obj) { const arr = this.data.folderContents[folderId] ||= []; if (arr.some(c => c.conversationId === obj.conversationId)) return; for (const fid in this.data.folderContents) { if (fid === folderId) continue; const before = this.data.folderContents[fid].length; this.data.folderContents[fid] = this.data.folderContents[fid] .filter(c => c.conversationId !== obj.conversationId); if (this.data.folderContents[fid].length < before) break; } arr.push({ ...obj, addedAt: Date.now() }); this.save(); this.refresh(); } removeConv(folderId, convId) { this.data.folderContents[folderId] = (this.data.folderContents[folderId] || []) .filter(c => c.conversationId !== convId); this.save(); this.refresh(); } removeConvFromAll(convId) { let changed = false; for (const fid in this.data.folderContents) { const before = this.data.folderContents[fid].length; this.data.folderContents[fid] = this.data.folderContents[fid] .filter(c => c.conversationId !== convId && !c.url?.includes(convId)); if (this.data.folderContents[fid].length < before) changed = true; } if (changed) { this.save(); this.refresh(); } } renameConv(folderId, convId, titleEl) { const conv = (this.data.folderContents[folderId] || []).find(c => c.conversationId === convId); if (!conv) return; const input = el('input', 'gv-folder-name-input'); input.value = conv.title; input.style.width = '100%'; const parent = titleEl.parentElement; titleEl.style.display = 'none'; parent.insertBefore(input, titleEl); input.focus(); input.select(); const done = (shouldSave) => { if (shouldSave) { const n = input.value.trim(); if (n) conv.title = n; this.save(); } input.remove(); titleEl.style.display = ''; titleEl.textContent = conv.title; }; input.addEventListener('blur', () => done(true)); input.addEventListener('keydown', e => { if (e.key === 'Enter') done(true); if (e.key === 'Escape') done(false); }); } moveFolderInto(parentId, folderId) { if (!folderId || folderId === parentId || this.isDescendant(parentId, folderId)) return; const targetDepth = this.getFolderDepth(parentId); const sourceMaxSubDepth = this.getSubtreeMaxDepth(folderId); if (targetDepth + 1 + sourceMaxSubDepth > MAX_DEPTH) { this._notify(t('folder_move_depth_limit'), 'error'); return; } const f = this.folder(folderId); if (f) { f.parentId = parentId; const siblings = this.children(parentId); const maxOrder = siblings.reduce((max, s) => Math.max(max, s.order ?? -1), -1); f.order = maxOrder + 1; this.save(); this.refresh(); } } moveFolderRelative(targetId, dragId, position) { if (dragId === targetId) return; if (this.isDescendant(targetId, dragId)) return; const targetFolder = this.folder(targetId); const dragFolder = this.folder(dragId); if (!targetFolder || !dragFolder) return; const parentId = targetFolder.parentId; const targetDepth = this.getFolderDepth(targetId); const sourceMaxSubDepth = this.getSubtreeMaxDepth(dragId); if (targetDepth + sourceMaxSubDepth > MAX_DEPTH) { this._notify(t('folder_move_depth_limit'), 'error'); return; } dragFolder.parentId = parentId; let siblings = this.data.folders.filter(f => f.parentId === parentId && f.id !== dragId); siblings = this.sorted(siblings); const targetIndex = siblings.findIndex(f => f.id === targetId); if (targetIndex === -1) return; if (position === 'before') { siblings.splice(targetIndex, 0, dragFolder); } else { siblings.splice(targetIndex + 1, 0, dragFolder); } siblings.forEach((f, index) => { f.order = index; }); this.save(); this.refresh(); } moveFolderToRoot(folderId) { const f = this.folder(folderId); if (f && f.parentId !== null) { f.parentId = null; const siblings = this.children(null); const maxOrder = siblings.reduce((max, s) => Math.max(max, s.order ?? -1), -1); f.order = maxOrder + 1; this.save(); this.refresh(); } } exportFolders() { if (this._exporting) return this._notify(t('folder_export_success'), 'info'); this._exporting = true; try { const payload = { format: 'gemini-voyager.folders.v1', exportedAt: new Date().toISOString(), data: this.data }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const a = el('a'); a.href = URL.createObjectURL(blob); a.download = `deepseek-voyager-folders-${Date.now()}.json`; document.body.append(a); a.click(); a.remove(); this._notify(t('folder_export_success'), 'success'); } catch (e) { console.error('[FolderManager] Export error:', e); this._notify(t('folder_import_error', { error: e }), 'error'); } finally { this._exporting = false; } } showImportDialog() { const overlay = el('div', 'gv-overlay'); const dialog = el('div', 'gv-dialog gv-import-dialog'); const strategy = { value: 'merge' }; dialog.append( el('div', 'gv-dialog-title', t('folder_import_title')), this._importStrategySection(strategy), this._importFileSection(), this._dialogButtons( t('btn_import'), async (fileInput) => { await this._handleImport(fileInput, strategy.value); overlay.remove(); }, () => overlay.remove() ) ); overlay.append(dialog); document.body.append(overlay); overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); } refresh() { const old = $('.gv-folder-list', this.containerEl); if (old) old.replaceWith(this._buildFolderList()); } _buildFolderList() { const list = el('div', 'gv-folder-list'); this._setupRootDrop(list); (this.data.folderContents[ROOT_ID] || []).forEach(c => list.append(this._convEl(c, ROOT_ID, 0)) ); this.sorted(this.children(null)).forEach(f => list.append(this._folderEl(f, 0))); return list; } _folderEl(folder, level) { const item = el('div', 'gv-folder-item'); item.dataset.folderId = folder.id; const header = el('div', 'gv-folder-item-header'); header.style.paddingLeft = `${level * 16 + 8}px`; const expandBtn = el('button', 'gv-folder-expand-btn'); expandBtn.append(mkIcon(folder.isExpanded ? 'expand_more' : 'chevron_right')); expandBtn.onclick = () => this.toggleExpand(folder.id); const nameSpan = el('span', 'gv-folder-name', folder.name); nameSpan.ondblclick = () => this.renameFolder(folder.id); nameSpan.onmouseenter = () => this._showTip(nameSpan, folder.name); nameSpan.onmouseleave = () => this._hideTip(); const menuBtn = el('button', 'gv-folder-actions-btn'); menuBtn.append(mkIcon('more_vert', 18)); menuBtn.onclick = e => this._folderMenu(e, folder.id); const folderIcon = mkIcon(folder.isExpanded ? 'folder_open' : 'folder'); header.append(expandBtn, folderIcon, nameSpan, menuBtn); this._setupDrop(header, folder.id); header.draggable = true; header.style.cursor = 'grab'; header.addEventListener('dragstart', e => { e.stopPropagation(); e.dataTransfer.setData('application/json', JSON.stringify({ type: 'folder', folderId: folder.id, title: folder.name })); header.style.opacity = '0.5'; }); header.addEventListener('dragend', () => header.style.opacity = '1'); item.append(header); if (folder.isExpanded) { const content = el('div', 'gv-folder-content'); (this.data.folderContents[folder.id] || []).forEach(c => content.append(this._convEl(c, folder.id, level + 1)) ); this.sorted(this.children(folder.id)).forEach(sub => content.append(this._folderEl(sub, level + 1)) ); item.append(content); } return item; } _convEl(conv, folderId, level) { const row = el('div', 'gv-folder-conversation'); row.dataset.conversationId = conv.conversationId; row.dataset.folderId = folderId; row.style.paddingLeft = `${level * 16 + 24}px`; const nativeTitle = this._syncConvTitle(conv.conversationId); if (nativeTitle && nativeTitle !== conv.title) { conv.title = nativeTitle; this.save(); } const titleSpan = el('span', 'gv-conversation-title', conv.title); titleSpan.onmouseenter = () => this._showTip(titleSpan, conv.title); titleSpan.onmouseleave = () => this._hideTip(); titleSpan.ondblclick = e => { e.stopPropagation(); this.renameConv(folderId, conv.conversationId, titleSpan); }; const rmBtn = el('button', 'gv-conversation-remove-btn'); rmBtn.append(mkIcon('close', 16)); rmBtn.title = t('folder_remove_conv'); rmBtn.onclick = e => { e.stopPropagation(); this._confirm(t('folder_remove_conv_confirm', { title: conv.title }), () => this.removeConv(folderId, conv.conversationId), rmBtn); }; row.onclick = () => this._navToConv(conv.url); row.draggable = true; row.addEventListener('dragstart', e => { e.stopPropagation(); e.dataTransfer.setData('application/json', JSON.stringify({ type: 'conversation', conversationId: conv.conversationId, title: conv.title, url: conv.url, sourceFolderId: folderId })); row.style.opacity = '0.5'; }); row.addEventListener('dragend', () => row.style.opacity = '1'); row.append(mkIcon('chat_bubble', 14), titleSpan, rmBtn); return row; } _setupDrop(dropEl, folderId) { dropEl.addEventListener('dragover', e => { e.preventDefault(); e.stopPropagation(); const rect = dropEl.getBoundingClientRect(); const y = e.clientY - rect.top; const h = rect.height; dropEl.classList.remove('gv-dragover-before', 'gv-dragover-inside', 'gv-dragover-after'); if (y < h * 0.25) { dropEl.classList.add('gv-dragover-before'); } else if (y > h * 0.75) { dropEl.classList.add('gv-dragover-after'); } else { dropEl.classList.add('gv-dragover-inside'); } }); dropEl.addEventListener('dragleave', () => { dropEl.classList.remove('gv-dragover-before', 'gv-dragover-inside', 'gv-dragover-after'); }); dropEl.addEventListener('drop', e => { e.preventDefault(); e.stopPropagation(); dropEl.classList.remove('gv-dragover-before', 'gv-dragover-inside', 'gv-dragover-after'); try { const obj = JSON.parse(e.dataTransfer?.getData('application/json') || ''); const rect = dropEl.getBoundingClientRect(); const y = e.clientY - rect.top; const h = rect.height; let position = 'inside'; if (y < h * 0.25) position = 'before'; else if (y > h * 0.75) position = 'after'; if (obj.type === 'folder') { if (position === 'inside') { this.moveFolderInto(folderId, obj.folderId); } else { this.moveFolderRelative(folderId, obj.folderId, position); } } else { this.addConv(folderId, obj); } } catch (err) { console.warn('[FolderManager] Drop parse error:', err); } }); } _setupRootDrop(rootEl) { rootEl.addEventListener('dragover', e => { if (e.dataTransfer?.types.includes('application/json')) { e.preventDefault(); rootEl.classList.add('gv-dragover'); } }); rootEl.addEventListener('dragleave', e => { const r = rootEl.getBoundingClientRect(); if (e.clientX <= r.left || e.clientX >= r.right || e.clientY <= r.top || e.clientY >= r.bottom) rootEl.classList.remove('gv-dragover'); }); rootEl.addEventListener('drop', e => { e.preventDefault(); e.stopPropagation(); rootEl.classList.remove('gv-dragover'); try { const obj = JSON.parse(e.dataTransfer?.getData('application/json') || ''); if (obj.type === 'folder') this.moveFolderToRoot(obj.folderId); else this.addConv(ROOT_ID, obj); } catch (err) { console.warn('[FolderManager] Root drop parse error:', err); } }); } _folderMenu(event, folderId) { event.stopPropagation(); const f = this.folder(folderId); if (!f) return; const items = []; const currentDepth = this.getFolderDepth(folderId); if (currentDepth < MAX_DEPTH) { items.push({ label: t('folder_create_subfolder'), fn: () => this.createFolder(folderId) }); } items.push( { label: t('folder_rename'), fn: () => this.renameFolder(folderId) }, { label: t('folder_delete'), fn: () => this.deleteFolder(folderId) } ); const menu = el('div', 'gv-menu'); menu.style.cssText = `position:fixed;left:${event.clientX}px;top:${event.clientY}px`; items.forEach(({ label, fn }) => { const btn = el('button', 'gv-menu-item', label); btn.onclick = () => { fn(); menu.remove(); }; menu.append(btn); }); document.body.append(menu); setTimeout(() => { const h = e => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', h); } }; document.addEventListener('click', h); }, 0); } _navToConv(url) { try { const id = new URL(url).pathname.match(/\/a\/chat\/s\/([a-f0-9-]{36})/)?.[1]; if (!id) { window.location.href = url; return; } const link = $(`a[href*="${id}"]`, this.sidebarEl); if (link) { link.click(); return; } history.pushState({}, '', url); dispatchEvent(new PopStateEvent('popstate', { state: {} })); } catch (err) { console.warn('[FolderManager] Navigation fallback:', err); window.location.href = url; } } _syncConvTitle(convId) { try { for (const link of $$(SEL.convItem, this.sidebarEl)) { if (link.getAttribute('href')?.includes(convId)) { return link.querySelector(SEL.convTitle)?.textContent?.trim() || link.textContent?.trim() || null; } } } catch (err) { console.warn('[FolderManager] Sync title error:', err); } return null; } _inlineInput(placeholder, onSave, initial = '') { const wrap = el('div', 'gv-inline-input'); const input = el('input', 'gv-folder-name-input'); input.placeholder = placeholder; input.value = initial; input.maxLength = 50; const saveBtn = el('button', 'gv-inline-btn gv-inline-save'); saveBtn.append(mkIcon('check', 18)); const cancelBtn = el('button', 'gv-inline-btn gv-inline-cancel'); cancelBtn.append(mkIcon('close', 18)); const save = () => { onSave(input.value.trim()); wrap.remove(); }; const cancel = () => { wrap.remove(); if (initial) this.refresh(); }; saveBtn.onclick = save; cancelBtn.onclick = cancel; input.onkeydown = e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') cancel(); }; wrap.append(input, saveBtn, cancelBtn); setTimeout(() => input.focus(), 0); return wrap; } _confirm(msg, onYes, refEl = null) { const box = el('div', 'gv-folder-confirm-dialog'); box.append(el('div', 'gv-confirm-msg', msg)); const actions = el('div', 'gv-confirm-actions'); const yes = el('button', 'gv-confirm-btn gv-confirm-yes', t('btn_delete')); const no = el('button', 'gv-confirm-btn gv-confirm-no', t('btn_cancel')); const overlay = el('div', 'gv-confirm-overlay'); const close = () => { box.remove(); overlay.remove(); }; yes.onclick = () => { onYes(); close(); }; no.onclick = close; actions.append(yes, no); box.append(actions); document.body.append(overlay); document.body.append(box); if (refEl) { const rect = refEl.getBoundingClientRect(); let top = rect.bottom + 4; let left = rect.left; box.style.visibility = 'hidden'; box.style.position = 'fixed'; box.style.top = '0px'; box.style.left = '0px'; const boxRect = box.getBoundingClientRect(); if (top + boxRect.height > window.innerHeight) top = rect.top - boxRect.height - 4; if (left + boxRect.width > window.innerWidth) left = window.innerWidth - boxRect.width - 8; if (top < 0) top = 8; if (left < 0) left = 8; box.style.top = `${top}px`; box.style.left = `${left}px`; box.style.visibility = 'visible'; } else { box.style.position = 'fixed'; box.style.top = '50%'; box.style.left = '50%'; box.style.transform = 'translate(-50%, -50%)'; } overlay.onclick = close; } _showTip(anchor, text) { if (!this._tooltip) { this._tooltip = el('div', 'gv-tooltip'); document.body.append(this._tooltip); } this._tooltip.textContent = text; const r = anchor.getBoundingClientRect(); this._tooltip.style.cssText = `left:${r.left}px;top:${r.bottom + 8}px`; this._tooltip.classList.add('show'); } _hideTip() { this._tooltip?.classList.remove('show'); } _notify(text, type = 'info') { const n = el('div', `gv-notification gv-notification-${type}`, text); document.body.append(n); setTimeout(() => n.classList.add('show'), 10); setTimeout(() => { n.classList.remove('show'); setTimeout(() => n.remove(), 300); }, 3000); } _importStrategySection(strategy) { const sec = el('div', 'gv-import-strategy'); const label = el('div', 'gv-import-strategy-label', t('folder_import_strategy')); const opts = el('div', 'gv-import-strategy-options'); const mergeOpt = el('label', 'gv-radio-opt'); const mergeInput = Object.assign(el('input'), { type: 'radio', name: 'import-strategy', value: 'merge', checked: true }); mergeOpt.append(mergeInput, el('span', '', t('folder_import_merge'))); const overOpt = el('label', 'gv-radio-opt'); const overInput = Object.assign(el('input'), { type: 'radio', name: 'import-strategy', value: 'overwrite' }); overOpt.append(overInput, el('span', '', t('folder_import_overwrite'))); overInput.onchange = () => strategy.value = 'overwrite'; mergeInput.onchange = () => strategy.value = 'merge'; opts.append(mergeOpt, overOpt); sec.append(label, opts); return sec; } _importFileSection() { const sec = el('div', 'gv-import-file-section'); const input = el('input'); input.type = 'file'; input.accept = '.json'; input.style.display = 'none'; const btn = el('button', 'gv-import-file-btn', t('folder_import_select_file')); const name = el('div', 'gv-import-file-name', ''); btn.onclick = () => input.click(); input.onchange = () => { if (input.files[0]) name.textContent = input.files[0].name; }; sec.append(input, btn, name); sec._fileInput = input; return sec; } _dialogButtons(primaryText, onPrimary, onCancel) { const wrap = el('div', 'gv-dialog-buttons'); const cancel = el('button', 'gv-dialog-btn gv-dialog-btn-secondary', t('btn_cancel')); const primary = el('button', 'gv-dialog-btn gv-dialog-btn-primary', primaryText); cancel.onclick = onCancel; primary.onclick = () => { const dialog = primary.closest('.gv-import-dialog'); const fileInput = dialog?.querySelector('input[type="file"]'); onPrimary(fileInput); }; wrap.append(cancel, primary); return wrap; } async _handleImport(fileInput, strategy) { if (this._importing) return; if (!fileInput?.files?.length) return this._notify(t('folder_import_select_file'), 'error'); this._importing = true; try { if (strategy === 'overwrite' && !confirm(t('folder_import_confirm_overwrite'))) return; const payload = JSON.parse(await fileInput.files[0].text()); if (payload.format !== 'gemini-voyager.folders.v1') return this._notify(t('folder_import_invalid'), 'error'); const imp = payload.data || { folders: [], folderContents: {} }; if (strategy === 'overwrite') { this.data = { folders: imp.folders, folderContents: imp.folderContents }; } else { this.data.folders.push(...imp.folders); for (const [id, arr] of Object.entries(imp.folderContents)) { (this.data.folderContents[id] ||= []).push(...arr); } } this.save(); this.refresh(); const fc = imp.folders.length, cc = Object.values(imp.folderContents).reduce((s, a) => s + a.length, 0); this._notify(t('folder_import_success', { folders: fc, conversations: cc }), 'success'); } catch (e) { console.error('[FolderManager] Import error:', e); this._notify(t('folder_import_error', { error: e }), 'error'); } finally { this._importing = false; } } _showMoveDialog(convId, title, url) { const overlay = el('div', 'gv-overlay'); const dialog = el('div', 'gv-dialog'); dialog.append(el('div', 'gv-dialog-title', t('conv_move_to_title'))); const list = el('div', 'gv-dialog-list'); const build = (pid, lv) => { this.sorted(this.children(pid)).forEach(f => { const btn = el('button', 'gv-dialog-item'); btn.style.paddingLeft = `${lv * 16 + 12}px`; btn.append(mkIcon('folder', 16), el('span', '', f.name)); btn.onclick = () => { this.addConv(f.id, { conversationId: convId, title, url }); overlay.remove(); }; list.append(btn); build(f.id, lv + 1); }); }; build(null, 0); const cancelBtn = el('button', 'gv-dialog-cancel', t('btn_cancel')); cancelBtn.onclick = () => overlay.remove(); dialog.append(list, cancelBtn); overlay.append(dialog); document.body.append(overlay); overlay.onclick = e => { if (e.target === overlay) overlay.remove(); }; } _makeNativeDraggable(item) { item.draggable = true; item.style.cursor = 'grab'; item.addEventListener('dragstart', e => { const convId = this._extractConvId(item); const title = item.querySelector('.conversation-title')?.textContent?.trim() || 'Untitled'; e.dataTransfer.setData('application/json', JSON.stringify({ type: 'conversation', conversationId: convId, title, url: `https://chat.deepseek.com/a/chat/s/${convId}`, })); item.style.opacity = '0.5'; }); item.addEventListener('dragend', () => item.style.opacity = '1'); } _extractConvId(elem) { const m = (elem.getAttribute('href') || '').match(/\/a\/chat\/s\/([a-f0-9-]{36})/i); return m?.[1] || null; } _injectMoveButton(menuContent) { if (!this._lastConvInfo) return; const { id, title, url } = this._lastConvInfo; if (!id || !title) return; const btn = el('button', 'mat-mdc-menu-item gv-move-btn'); btn.setAttribute('role', 'menuitem'); btn.setAttribute('tabindex', '0'); btn.append(mkIcon('folder', 18), el('span', 'mat-mdc-menu-item-text', el('span', '', t('conv_move_to')))); btn.onclick = e => { e.stopPropagation(); this._showMoveDialog(id, title, url); menuContent.closest('.mat-mdc-menu-panel')?.remove(); }; const pinBtn = menuContent.querySelector('[data-test-id="pin-button"]'); menuContent.insertBefore(btn, pinBtn?.nextSibling || menuContent.firstChild); } _isConvMenu(node) { const mc = node.querySelector('.mat-mdc-menu-content'); if (!mc) return false; return !!mc.querySelector('[data-test-id="pin-button"],[data-test-id="rename-button"],[data-test-id="delete-button"]'); } async init() { this.load(); await this._waitForSidebar(); this._buildContainer(); this._makeNativeDraggableAll(); this._observeSidebar(); this._observeNativeMenu(); this._trackConvClick(); } async _waitForSidebar() { return new Promise(resolve => { const check = () => { const s = $(SEL.sidebar); if (s) { this.sidebarEl = s; resolve(); } else setTimeout(check, 500); }; check(); }); } _buildContainer() { this.containerEl = el('div', 'gv-folder-container'); const header = el('div', 'gv-folder-header'); const titleWrap = el('div', 'title-container'); const inner = el('div'); inner.style.cssText = 'display:flex;align-items:center;gap:8px;padding-left:12px'; const bookshelfIcon = mkIcon('bookshelf', 20); bookshelfIcon.style.cursor = 'pointer'; bookshelfIcon.title = t('folder_toggle_collapse'); bookshelfIcon.onclick = () => this.toggleCollapse(); inner.append( bookshelfIcon, (() => { const s = el('span', '', t('folder_title')); s.style.cssText = 'font-size:12px;opacity:.6;font-weight:500;color:#000'; return s; })() ); titleWrap.append(inner); const actions = el('div', 'gv-folder-header-actions'); const importBtn = el('button', 'gv-action-btn'); importBtn.append(mkIcon('upload', 18)); importBtn.title = t('folder_import'); importBtn.onclick = () => this.showImportDialog(); const exportBtn = el('button', 'gv-action-btn'); exportBtn.append(mkIcon('download', 18)); exportBtn.title = t('folder_export'); exportBtn.onclick = () => this.exportFolders(); const addBtn = el('button', 'gv-add-btn'); addBtn.append(mkIcon('add', 20)); addBtn.title = t('folder_create'); addBtn.onclick = () => this.createFolder(); actions.append(importBtn, exportBtn, addBtn); header.append(titleWrap, actions); this._setupRootDrop(header); this.containerEl.append(header, this._buildFolderList()); this.sidebarEl.parentElement?.insertBefore(this.containerEl, this.sidebarEl); } _makeNativeDraggableAll() { $$(SEL.convItem, this.sidebarEl).forEach(i => this._makeNativeDraggable(i)); } _observeSidebar() { new MutationObserver(muts => muts.forEach(m => { m.addedNodes.forEach(n => { if (n instanceof HTMLElement) $$(SEL.convItem, n).forEach(i => this._makeNativeDraggable(i)); }); m.removedNodes.forEach(n => { if (n instanceof HTMLElement) $$(SEL.convItem, n).forEach(l => { const id = this._extractConvId(l); if (id) this.removeConvFromAll(id); }); }); })).observe(this.sidebarEl, { childList: true, subtree: true }); } _observeNativeMenu() { new MutationObserver(muts => muts.forEach(m => { m.addedNodes.forEach(n => { if (!(n instanceof HTMLElement)) return; const mc = n.querySelector('.mat-mdc-menu-content'); if (mc && !mc.querySelector('.gv-move-btn') && this._isConvMenu(n)) this._injectMoveButton(mc); }); m.removedNodes.forEach(n => { if (n instanceof HTMLElement && (n.classList?.contains('mat-mdc-menu-panel') || n.querySelector('.mat-mdc-menu-panel'))) this._lastConvInfo = null; }); })).observe(document.body, { childList: true, subtree: true }); } _trackConvClick() { document.addEventListener('click', e => { const moreBtn = e.target.closest('[data-test-id="actions-menu-button"]'); if (!moreBtn) return; const convEl = moreBtn.closest('[data-test-id="conversation"]'); if (!convEl) return; const id = this._extractConvId(convEl); const title = convEl.querySelector(SEL.convTitle)?.textContent?.trim() || convEl.textContent?.trim(); const href = convEl.getAttribute('href'); if (id && title) this._lastConvInfo = { id, title, url: href ? `https://chat.deepseek.com${href}` : `https://chat.deepseek.com/a/chat/s/${id}` }; }, true); } } /* ═══════════════════════════════════════════ CSS 样式 ═══════════════════════════════════════════ */ GM_addStyle(` /* ── 基础 ── */ .gv-icon, .gv-icon svg { display:inline-flex; align-items:center; justify-content:center } .gv-icon svg { width:100%; height:100% } /* ── 容器 & 头部 ── */ .gv-folder-container { margin-bottom:16px; padding:8px 8px 8px 12px } .gv-folder-header { display:flex; align-items:center; justify-content:space-between; padding:8px 16px 8px 0; margin-bottom:8px; border-radius:8px } .gv-folder-header .title-container { flex:1 } .gv-folder-header-actions { display:flex; align-items:center; gap:4px } .gv-action-btn, .gv-add-btn { background:none; border:none; padding:4px; cursor:pointer; border-radius:50%; display:flex; align-items:center; justify-content:center; color:#000; transition:background .2s } .gv-action-btn:hover, .gv-add-btn:hover { background:rgba(0,0,0,.06) } .gv-add-btn { width:32px; height:32px } /* ── 折叠控制 ── */ .gv-folder-container.gv-collapsed .gv-folder-list { display: none !important; } .gv-folder-container.gv-collapsed .title-container svg { opacity: 0.4; } /* ── 文件夹列表 ── */ .gv-folder-list { display:flex; flex-direction:column; gap:2px; min-height:40px; padding:4px; border-radius:8px } /* ── 文件夹项 ── */ .gv-folder-item { display:flex; flex-direction:column } .gv-folder-item-header { display:flex; align-items:center; gap:8px; padding:8px 12px; border-radius:8px; cursor:pointer; transition:background .2s; position:relative } .gv-folder-item-header:hover { background:rgba(0,0,0,.04) } .gv-folder-item-header[draggable="true"] { cursor:grab } .gv-folder-item-header[draggable="true"]:active { cursor:grabbing } .gv-folder-expand-btn { width:20px; height:20px; border:none; background:transparent; color:#000; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; flex-shrink:0 } .gv-folder-item-header > svg { flex-shrink:0; color:#000 } .gv-folder-name { flex:1; font-size:14px; color:#000; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; user-select:none } .gv-folder-actions-btn { width:24px; height:24px; border:none; background:transparent; color:#000; cursor:pointer; display:flex; align-items:center; justify-content:center; border-radius:4px; opacity:0; transition:opacity .2s,background .2s; flex-shrink:0; margin-left:auto } .gv-folder-item-header:hover .gv-folder-actions-btn { opacity:1 } .gv-folder-actions-btn:hover { background:rgba(0,0,0,.06) } /* ── 文件夹内容 ── */ .gv-folder-content { display:flex; flex-direction:column; gap:2px; margin-top:2px } /* ── 对话项 ── */ .gv-folder-conversation { display:flex; align-items:center; gap:8px; padding:8px 12px; border-radius:8px; cursor:pointer; transition:background .2s,opacity .2s } .gv-folder-conversation:hover { background:rgba(0,0,0,.04) } .gv-folder-conversation[draggable="true"] { cursor:grab } .gv-folder-conversation[draggable="true"]:active { cursor:grabbing } .gv-folder-conversation > svg { flex-shrink:0; color:#000 } .gv-conversation-title { flex:1; font-size:14px; color:#000; overflow:hidden; text-overflow:ellipsis; white-space:nowrap } .gv-conversation-remove-btn { min-width:24px; width:24px; height:24px; border:none; background:transparent; color:#000; cursor:pointer; display:flex; align-items:center; justify-content:center; border-radius:4px; opacity:0; transition:opacity .2s,background .2s; flex-shrink:0; margin-left:4px } .gv-folder-conversation:hover .gv-conversation-remove-btn { opacity:1 } .gv-conversation-remove-btn:hover { background:rgba(200,0,0,.08); color:#b71c1c } /* ── 拖拽反馈:三段式(伪元素绘制纯直线) ── */ .gv-folder-item-header.gv-dragover-inside { background:rgba(0,0,0,.06)!important } .gv-folder-item-header.gv-dragover-before::before { content:''; position:absolute; top:-2px; left:12px; right:12px; height:2px; background:#000; border-radius:0; pointer-events:none; z-index:1 } .gv-folder-item-header.gv-dragover-after::after { content:''; position:absolute; bottom:-2px; left:12px; right:12px; height:2px; background:#000; border-radius:0; pointer-events:none; z-index:1 } .gv-folder-list.gv-dragover { background:rgba(0,0,0,.03)!important; border:2px dashed #ccc!important } /* ── 菜单 ── */ .gv-menu { background:#fff; border:1px solid #e0e0e0; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,.15); padding:4px; z-index:2147483647; min-width:160px } .gv-menu-item { display:block; width:100%; padding:8px 12px; border:none; background:transparent; color:#000; text-align:left; cursor:pointer; border-radius:4px; font-size:14px; transition:background .2s } .gv-menu-item:hover { background:rgba(0,0,0,.06) } /* ── 内联输入 ── */ .gv-inline-input { display:flex; align-items:center; gap:8px; padding:8px 12px; background:rgba(0,0,0,.03); border-radius:8px; margin:4px 0 } .gv-folder-name-input { flex:1; padding:6px 8px; border:1px solid #ccc; border-radius:4px; background:#fff; color:#000; font-size:14px; outline:none } .gv-folder-name-input:focus { border-color:#000; box-shadow:0 0 0 2px rgba(0,0,0,.1) } .gv-inline-btn { width:24px; height:24px; border:none; background:transparent; color:#000; cursor:pointer; display:flex; align-items:center; justify-content:center; border-radius:4px; padding:0; transition:background .2s,color .2s; flex-shrink:0 } .gv-inline-save:hover { color:#4d6bfe; background:rgba(230,230,230,.1) } .gv-inline-cancel:hover { color:#c62828; background:rgba(230,230,230,.1) } /* ── 确认框及其遮罩 ── */ .gv-confirm-overlay { position:fixed; inset:0; z-index:2147483646; background:transparent } .gv-folder-confirm-dialog { background:#fff; border:1px solid #e0e0e0; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.2); padding:16px; z-index:2147483647; min-width:240px; max-width:320px } .gv-confirm-msg { color:#000; font-size:14px; line-height:1.5; margin-bottom:16px } .gv-confirm-actions { display:flex; gap:8px; justify-content:flex-end } .gv-confirm-btn { padding:8px 16px; border-radius:6px; border:1px solid #ccc; background:#fff; color:#000; font-size:13px; cursor:pointer; transition:background .2s } .gv-confirm-yes { background:rgba(198,40,40,.08); border-color:rgba(198,40,40,.3); color:#c62828 } .gv-confirm-yes:hover { background:rgba(198,40,40,.15) } /* ── 对话框通用 ── */ .gv-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5); z-index:2147483647; display:flex; align-items:center; justify-content:center } .gv-dialog { background:#fff; border:1px solid #e0e0e0; border-radius:12px; box-shadow:0 4px 12px rgba(0,0,0,.15); min-width:320px; max-width:400px; max-height:80vh; display:flex; flex-direction:column } .gv-dialog-title { font-size:16px; font-weight:500; padding:16px; border-bottom:1px solid #e0e0e0; color:#000 } .gv-dialog-list { flex:1; overflow-y:auto; padding:8px; min-height:200px; max-height:400px } .gv-dialog-item { display:flex; align-items:center; gap:8px; width:100%; padding:10px 12px; border:none; background:transparent; color:#000; font-size:14px; cursor:pointer; border-radius:6px; text-align:left; transition:background .2s } .gv-dialog-item:hover { background:rgba(0,0,0,.04) } .gv-dialog-item > svg { flex-shrink:0; color:#000 } .gv-dialog-cancel { padding:12px 16px; border:none; border-top:1px solid #e0e0e0; background:transparent; color:#000; font-size:14px; cursor:pointer; border-radius:0 0 12px 12px } .gv-dialog-cancel:hover { background:rgba(0,0,0,.04) } .gv-dialog-buttons { display:flex; gap:12px; justify-content:flex-end; padding:16px } .gv-dialog-btn { padding:10px 24px; border-radius:8px; border:none; font-size:14px; font-weight:500; cursor:pointer; transition:all .2s } .gv-dialog-btn-primary { background:#4d6bfe; color:#fff } .gv-dialog-btn-primary:hover { background:#4d6bfe } .gv-dialog-btn-secondary { background:transparent; color:#000; border:1px solid #ccc } .gv-dialog-btn-secondary:hover { background:rgba(0,0,0,.04) } /* ── 导入对话框 ── */ .gv-import-dialog { padding:0; min-width:400px } .gv-import-dialog .gv-dialog-title { padding:24px 24px 0 } .gv-import-strategy { margin:20px 24px } .gv-import-strategy-label { font-size:14px; font-weight:500; margin-bottom:12px; color:#000 } .gv-import-strategy-options { display:flex; flex-direction:column; gap:8px } .gv-radio-opt { display:flex; align-items:center; padding:12px; border:1px solid #ccc; border-radius:8px; cursor:pointer; transition:all .2s; color:#000 } .gv-radio-opt:hover { background:rgba(0,0,0,.03); border-color:#000 } .gv-radio-opt input[type='radio'] { margin-right:12px; cursor:pointer } .gv-radio-opt span { font-size:14px; color:#000 } .gv-import-file-section { margin:0 24px 20px } .gv-import-file-btn { background:#4d6bfe; color:#fff; border:none; padding:10px 20px; border-radius:8px; cursor:pointer; font-size:14px; font-weight:500; width:100%; transition:background .2s } .gv-import-file-btn:hover { background:#4d6bfe } .gv-import-file-name { margin-top:12px; font-size:13px; color:#000; padding:8px; background:rgba(0,0,0,.03); border-radius:4px; text-align:center; min-height:32px } /* ── 提示 ── */ .gv-tooltip { position:fixed; background:rgba(0,0,0,.9); color:#fff; padding:8px 12px; border-radius:6px; font-size:13px; max-width:300px; z-index:10000; pointer-events:none; opacity:0; transition:opacity .15s } .gv-tooltip.show { opacity:1 } /* ── 通知 ── */ .gv-notification { position:fixed; bottom:24px; right:24px; padding:12px 20px; border-radius:8px; font-size:14px; font-weight:500; color:#fff; z-index:2147483647; box-shadow:0 4px 16px rgba(0,0,0,.2); opacity:0; transform:translateY(20px); transition:all .3s; max-width:400px } .gv-notification.show { opacity:1; transform:translateY(0) } .gv-notification-success { background:linear-gradient(#4d6bfe) } .gv-notification-error { background:linear-gradient(#c62828) } .gv-notification-info { background:linear-gradient(#000000) } /* ── 辅助 ── */ .gv-hidden { display:none!important } [data-test-id="conversation"][draggable="true"] { cursor:grab } [data-test-id="conversation"][draggable="true"]:active { cursor:grabbing } `); /* ═══════════════════════════════════════════ 启动 ═══════════════════════════════════════════ */ if (location.hostname === 'chat.deepseek.com') { new FolderManager().init().catch(e => console.error('[FolderManager] Init error:', e)); }