Last active
March 30, 2026 08:51
-
-
Save guersam/cdb595449b89cb2e1423ec2659dd36fd to your computer and use it in GitHub Desktop.
TamperMonkey Claude Project Knowledge Tools
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Claude Artifact Batch Add to Project | |
| // @namespace https://claude.ai | |
| // @version 1.2 | |
| // @description 특정 메시지의 아티팩트들을 일괄로 프로젝트에 추가 (실시간 추적) | |
| // @match https://claude.ai/chat/* | |
| // @grant none | |
| // @run-at document-idle | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ── 상수 ────────────────────────────────────────── | |
| const BUTTON_ATTR = 'data-batch-add-btn'; | |
| const SCANNED_ATTR = 'data-batch-scanned-count'; // 마지막 스캔 시 아티팩트 수 기록 | |
| const FOLDER_PLUS_SVG = | |
| '<svg xmlns="http://www.w3.org/2000/svg" width="1.1rem" height="1.1rem" ' + | |
| 'fill="currentColor" viewBox="0 0 256 256" style="flex-shrink:0">' + | |
| '<path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,' + | |
| '24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88' + | |
| 'A16,16,0,0,0,216,72Zm0,128H40V56H92.69l28,28H216Zm-88-24a8,8,0,0,0,8-8V148h20' + | |
| 'a8,8,0,0,0,0-16H136V112a8,8,0,0,0-16,0v20H100a8,8,0,0,0,0,16h20v20A8,8,0,0,0,' + | |
| '128,176Z"></path></svg>'; | |
| // ── 유틸리티 ────────────────────────────────────── | |
| function getOrgId() { | |
| return document.cookie.match(/lastActiveOrg=([^;]+)/)?.[1]; | |
| } | |
| function getConvId() { | |
| return location.pathname.match(/\/chat\/([^/?#]+)/)?.[1]; | |
| } | |
| function getProjectId() { | |
| const a = document.querySelector('a[href*="/project/"]'); | |
| return a?.href?.match(/\/project\/([^/?#]+)/)?.[1]; | |
| } | |
| function getReactFiber(el) { | |
| const k = Object.keys(el).find(k => k.startsWith('__reactFiber$')); | |
| return k ? el[k] : null; | |
| } | |
| function getFileInfo(artifactEl) { | |
| try { | |
| let fiber = getReactFiber(artifactEl); | |
| // fiber 트리를 여러 단계 탐색하여 file prop 찾기 | |
| for (let i = 0; i < 8 && fiber; i++) { | |
| const file = fiber.memoizedProps?.file; | |
| if (file?.name && file?.path) return { name: file.name, path: file.path }; | |
| fiber = fiber.return; | |
| } | |
| return null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| // ── 스트리밍 상태 감지 ──────────────────────────── | |
| function isStreaming() { | |
| // Claude가 응답 중일 때 나타나는 stop 버튼 감지 | |
| return !!( | |
| document.querySelector('button[aria-label="Stop Response"]') || | |
| document.querySelector('button[aria-label="Stop"]') || | |
| document.querySelector('[data-testid="stop-button"]') | |
| ); | |
| } | |
| // ── API ─────────────────────────────────────────── | |
| async function downloadFile(orgId, convId, filePath) { | |
| const url = | |
| `/api/organizations/${orgId}/conversations/${convId}` + | |
| `/wiggle/download-file?path=${encodeURIComponent(filePath)}`; | |
| const r = await fetch(url, { credentials: 'same-origin' }); | |
| if (!r.ok) throw new Error(`Download ${r.status}: ${filePath}`); | |
| return r.text(); | |
| } | |
| async function addToProject(orgId, projectId, fileName, content) { | |
| const url = | |
| `/api/organizations/${orgId}/projects/${projectId}/docs`; | |
| const r = await fetch(url, { | |
| method: 'POST', | |
| credentials: 'same-origin', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ file_name: fileName, content }), | |
| }); | |
| if (!r.ok) throw new Error(`Add ${r.status}: ${fileName}`); | |
| return r.json(); | |
| } | |
| // ── 아티팩트 그룹 감지 (개선) ───────────────────── | |
| function findArtifactGroups() { | |
| const all = document.querySelectorAll( | |
| '[role="button"][aria-label*="Open artifact"]' | |
| ); | |
| const map = new Map(); // container → [artifactEl, ...] | |
| const assigned = new Set(); // 중복 등록 방지 | |
| all.forEach(el => { | |
| if (assigned.has(el)) return; | |
| let c = el.parentElement; | |
| for (let i = 0; i < 15 && c; i++, c = c.parentElement) { | |
| // 메시지 블록 경계 탐색: 여러 아티팩트를 포함하는 가장 가까운 컨테이너 | |
| const siblings = c.querySelectorAll( | |
| '[role="button"][aria-label*="Open artifact"]' | |
| ); | |
| // 조건 1: 아티팩트가 2개 이상인 컨테이너 | |
| // 조건 2: 단일 아티팩트라도 메시지 블록 수준의 컨테이너 (py-2 또는 message 관련 클래스) | |
| const isMessageBlock = | |
| siblings.length > 1 || | |
| c.classList.contains('py-2') || | |
| c.getAttribute('data-testid')?.includes('message'); | |
| if (isMessageBlock) { | |
| if (!map.has(c)) map.set(c, []); | |
| const arr = map.get(c); | |
| siblings.forEach(s => { | |
| if (!assigned.has(s)) { | |
| assigned.add(s); | |
| arr.push(s); | |
| } | |
| }); | |
| break; | |
| } | |
| } | |
| }); | |
| return map; | |
| } | |
| // ── 버튼 삽입 위치 찾기 (개선) ──────────────────── | |
| function findInsertionPoint(container) { | |
| // 1순위: "Download all" 버튼 옆 | |
| const dlAll = Array.from(container.querySelectorAll('button')) | |
| .find(b => b.textContent.trim() === 'Download all'); | |
| if (dlAll) return { parent: dlAll.parentElement, after: dlAll }; | |
| // 2순위: 아티팩트 목록 바로 다음의 flex 컨테이너 (action bar) | |
| const artifacts = container.querySelectorAll( | |
| '[role="button"][aria-label*="Open artifact"]' | |
| ); | |
| if (artifacts.length > 0) { | |
| const lastArtifact = artifacts[artifacts.length - 1]; | |
| // action bar를 찾기: 아티팩트 다음에 오는 버튼 그룹 | |
| let node = lastArtifact.parentElement; | |
| for (let i = 0; i < 5 && node; i++, node = node.parentElement) { | |
| const actionBar = node.querySelector('div[class*="flex"]'); | |
| if (actionBar && actionBar.querySelector('button')) { | |
| return { parent: actionBar, after: null }; | |
| } | |
| } | |
| } | |
| // 3순위: 컨테이너 끝에 추가 | |
| return { parent: container, after: null }; | |
| } | |
| // ── UI (개선: 업데이트 지원) ─────────────────────── | |
| function applyBaseStyle(btn) { | |
| Object.assign(btn.style, { | |
| display: 'inline-flex', | |
| alignItems: 'center', | |
| gap: '4px', | |
| height: '36px', | |
| boxSizing: 'border-box', | |
| margin: '0 0 0 8px', | |
| padding: '8px 12px 8px 8px', | |
| fontSize: '14px', | |
| fontWeight: '500', | |
| lineHeight: '19.6px', | |
| color: 'rgb(180, 83, 9)', | |
| background: 'transparent', | |
| border: '0.8px solid rgba(180, 83, 9, 0.4)', | |
| borderRadius: '8px', | |
| cursor: 'pointer', | |
| transition: 'all 0.15s', | |
| }); | |
| } | |
| function updateOrCreateButton(artifacts, container) { | |
| const count = artifacts.length; | |
| if (count < 1) return; | |
| const existing = container.querySelector(`[${BUTTON_ATTR}]`); | |
| const prevCount = existing | |
| ? parseInt(existing.getAttribute(SCANNED_ATTR) || '0', 10) | |
| : 0; | |
| // 이미 같은 수의 아티팩트로 버튼이 존재하면 스킵 | |
| if (existing && prevCount === count) return; | |
| // 기존 버튼이 있지만 카운트가 달라졌으면 제거 후 재생성 | |
| if (existing) { | |
| existing.remove(); | |
| } | |
| const btn = document.createElement('button'); | |
| btn.setAttribute(BUTTON_ATTR, 'true'); | |
| btn.setAttribute(SCANNED_ATTR, String(count)); | |
| btn.innerHTML = `${FOLDER_PLUS_SVG} Add ${count} to Project`; | |
| applyBaseStyle(btn); | |
| btn.addEventListener('mouseenter', () => { | |
| if (btn.disabled) return; | |
| btn.style.background = '#fffbeb'; | |
| btn.style.borderColor = 'rgba(180, 83, 9, 0.6)'; | |
| }); | |
| btn.addEventListener('mouseleave', () => { | |
| if (btn.disabled) return; | |
| btn.style.background = 'transparent'; | |
| btn.style.borderColor = 'rgba(180, 83, 9, 0.4)'; | |
| }); | |
| btn.addEventListener('click', async () => { | |
| const orgId = getOrgId(); | |
| const convId = getConvId(); | |
| const projectId = getProjectId(); | |
| if (!orgId || !convId || !projectId) { | |
| alert('프로젝트 정보를 찾을 수 없습니다.\n이 채팅이 프로젝트에 속해 있는지 확인하세요.'); | |
| return; | |
| } | |
| // ★ 클릭 시점에 아티팩트를 다시 탐색하여 최신 fiber 정보 사용 | |
| const freshArtifacts = container.querySelectorAll( | |
| '[role="button"][aria-label*="Open artifact"]' | |
| ); | |
| const files = Array.from(freshArtifacts).map(getFileInfo).filter(Boolean); | |
| if (!files.length) { | |
| alert('아티팩트 파일 정보를 추출할 수 없습니다.\n잠시 후 다시 시도해 주세요.'); | |
| return; | |
| } | |
| // 중복 파일명 제거 (같은 아티팩트가 중복 매핑된 경우 방어) | |
| const uniqueFiles = []; | |
| const seen = new Set(); | |
| for (const f of files) { | |
| if (!seen.has(f.path)) { | |
| seen.add(f.path); | |
| uniqueFiles.push(f); | |
| } | |
| } | |
| const list = uniqueFiles.map(f => ` • ${f.name}`).join('\n'); | |
| if (!confirm( | |
| `${uniqueFiles.length}개 아티팩트를 프로젝트에 추가합니다:\n\n${list}\n\n계속하시겠습니까?` | |
| )) return; | |
| btn.disabled = true; | |
| btn.style.opacity = '0.6'; | |
| btn.style.cursor = 'wait'; | |
| const origHTML = btn.innerHTML; | |
| let ok = 0, fail = 0; | |
| for (let i = 0; i < uniqueFiles.length; i++) { | |
| const f = uniqueFiles[i]; | |
| btn.innerHTML = `${FOLDER_PLUS_SVG} ${i + 1}/${uniqueFiles.length} ${f.name}`; | |
| try { | |
| const content = await downloadFile(orgId, convId, f.path); | |
| await addToProject(orgId, projectId, f.name, content); | |
| ok++; | |
| } catch (e) { | |
| console.error('[BatchAdd]', f.name, e); | |
| fail++; | |
| } | |
| await new Promise(r => setTimeout(r, 300)); | |
| } | |
| btn.disabled = false; | |
| btn.style.opacity = '1'; | |
| btn.style.cursor = 'pointer'; | |
| btn.innerHTML = `${FOLDER_PLUS_SVG} ✅ ${ok} added` + (fail ? ` ❌ ${fail} failed` : ''); | |
| btn.style.color = fail ? '#dc2626' : '#16a34a'; | |
| btn.style.borderColor = fail ? '#fca5a5' : '#86efac'; | |
| setTimeout(() => { | |
| btn.innerHTML = origHTML; | |
| btn.style.color = 'rgb(180, 83, 9)'; | |
| btn.style.borderColor = 'rgba(180, 83, 9, 0.4)'; | |
| btn.style.background = 'transparent'; | |
| }, 5000); | |
| }); | |
| // 삽입 | |
| const { parent, after } = findInsertionPoint(container); | |
| if (after) { | |
| parent.insertBefore(btn, after.nextSibling); | |
| } else { | |
| parent.appendChild(btn); | |
| } | |
| } | |
| // ── Debounce with maxWait ───────────────────────── | |
| // | |
| // 일반 debounce는 mutation이 계속 오면 영원히 지연됨. | |
| // maxWait를 두어 최소 N ms마다 1회는 scan이 실행되도록 보장. | |
| function debounceWithMaxWait(fn, wait, maxWait) { | |
| let timer = null; | |
| let lastInvoke = 0; | |
| return function (...args) { | |
| const now = Date.now(); | |
| clearTimeout(timer); | |
| const sinceLastInvoke = now - lastInvoke; | |
| if (sinceLastInvoke >= maxWait) { | |
| // maxWait 초과: 즉시 실행 | |
| lastInvoke = now; | |
| fn(...args); | |
| } else { | |
| // 아직 maxWait 내: debounce 유지하되 maxWait 한도 내에서 | |
| const remaining = maxWait - sinceLastInvoke; | |
| timer = setTimeout(() => { | |
| lastInvoke = Date.now(); | |
| fn(...args); | |
| }, Math.min(wait, remaining)); | |
| } | |
| }; | |
| } | |
| // ── 스캔 로직 ───────────────────────────────────── | |
| function scan() { | |
| findArtifactGroups().forEach((arts, container) => { | |
| updateOrCreateButton(arts, container); | |
| }); | |
| } | |
| // ── 스트리밍 종료 감지 ──────────────────────────── | |
| // | |
| // 스트리밍이 끝나면 최종 scan을 수행하여 | |
| // fiber 정보가 완전히 갖춰진 상태에서 버튼을 갱신. | |
| let wasStreaming = false; | |
| function checkStreamingEnd() { | |
| const streaming = isStreaming(); | |
| if (wasStreaming && !streaming) { | |
| // 스트리밍이 방금 끝남 → 약간의 딜레이 후 최종 scan | |
| setTimeout(scan, 800); | |
| setTimeout(scan, 2000); // 안전을 위해 한 번 더 | |
| } | |
| wasStreaming = streaming; | |
| } | |
| // ── Observer 설정 ───────────────────────────────── | |
| // 초기 스캔 | |
| setTimeout(scan, 1500); | |
| // debounce: 500ms 대기, 최대 2000ms마다 1회 보장 | |
| const debouncedScan = debounceWithMaxWait(scan, 500, 2000); | |
| const obs = new MutationObserver((mutations) => { | |
| debouncedScan(); | |
| checkStreamingEnd(); | |
| }); | |
| obs.observe(document.body, { childList: true, subtree: true }); | |
| // 스트리밍 종료를 폴링으로도 감지 (MutationObserver가 놓칠 수 있으므로) | |
| setInterval(checkStreamingEnd, 1500); | |
| // ── URL 변경 감지 (SPA 네비게이션) ──────────────── | |
| // | |
| // claude.ai는 SPA이므로 채팅 전환 시 URL이 바뀌지만 | |
| // 페이지가 새로고침되지 않음. 이 경우 재스캔 필요. | |
| let lastUrl = location.href; | |
| const urlObserver = new MutationObserver(() => { | |
| if (location.href !== lastUrl) { | |
| lastUrl = location.href; | |
| // 새 채팅으로 전환: 기존 타이머 정리 후 재스캔 | |
| setTimeout(scan, 1500); | |
| setTimeout(scan, 3000); | |
| } | |
| }); | |
| urlObserver.observe(document.querySelector('head > title') || document.head, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true, | |
| }); | |
| // popstate (뒤로가기/앞으로가기) | |
| window.addEventListener('popstate', () => { | |
| setTimeout(scan, 1500); | |
| }); | |
| console.log('[Claude Batch Add] v1.2 loaded — real-time tracking enabled'); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Claude Project Knowledge Tools | |
| // @namespace https://claude.ai/ | |
| // @version 1.7 | |
| // @description Backup project files (+ instructions as CLAUDE.md), multi-select files by name, highlight duplicates with KEEP/OLD distinction | |
| // @match https://claude.ai/project/* | |
| // @grant GM_registerMenuCommand | |
| // @grant GM_setClipboard | |
| // @grant GM_getValue | |
| // @grant GM_setValue | |
| // @run-at document-idle | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ═══════════════════════════════════════════════════════════ | |
| // ── Load JSZip in the PAGE context (avoids sandbox hang) ── | |
| // ═══════════════════════════════════════════════════════════ | |
| function loadPageJSZip() { | |
| return new Promise((resolve, reject) => { | |
| if (unsafeWindow.JSZip) return resolve(unsafeWindow.JSZip); | |
| const s = document.createElement('script'); | |
| s.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js'; | |
| s.onload = () => { | |
| if (unsafeWindow.JSZip) resolve(unsafeWindow.JSZip); | |
| else reject(new Error('JSZip loaded but not found on window')); | |
| }; | |
| s.onerror = () => reject(new Error('Failed to load JSZip from CDN')); | |
| document.head.appendChild(s); | |
| }); | |
| } | |
| // ═══════════════════════════════════════════════════════════ | |
| // ── Toast notification system ── | |
| // ═══════════════════════════════════════════════════════════ | |
| const TOAST_CONTAINER_ID = '__ct-toast-container'; | |
| const TOAST_STYLE_ID = '__ct-toast-styles'; | |
| function injectToastStyles() { | |
| if (document.getElementById(TOAST_STYLE_ID)) return; | |
| const style = document.createElement('style'); | |
| style.id = TOAST_STYLE_ID; | |
| style.textContent = ` | |
| #${TOAST_CONTAINER_ID} { | |
| position: fixed; | |
| bottom: 24px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 100000; | |
| display: flex; | |
| flex-direction: column-reverse; | |
| align-items: center; | |
| gap: 10px; | |
| pointer-events: none; | |
| } | |
| .ct-toast { | |
| pointer-events: auto; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 12px 20px; | |
| border-radius: 10px; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| font-size: 14px; | |
| font-weight: 500; | |
| color: #fff; | |
| box-shadow: 0 6px 24px rgba(0,0,0,.2); | |
| opacity: 0; | |
| transform: translateY(16px) scale(0.97); | |
| animation: ctToastIn 0.35s ease forwards; | |
| max-width: 480px; | |
| } | |
| .ct-toast.ct-toast-out { | |
| animation: ctToastOut 0.3s ease forwards; | |
| } | |
| .ct-toast-success { background: #1a7f37; } | |
| .ct-toast-info { background: #0969da; } | |
| .ct-toast-warning { background: #bf8700; } | |
| .ct-toast-error { background: #cf222e; } | |
| .ct-toast-progress { background: #333; } | |
| .ct-toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1; } | |
| .ct-toast-body { flex: 1; line-height: 1.4; } | |
| .ct-toast-body small { | |
| display: block; font-weight: 400; font-size: 12px; opacity: 0.85; margin-top: 2px; | |
| } | |
| .ct-toast-dismiss { | |
| background: none; border: none; color: rgba(255,255,255,.7); | |
| font-size: 16px; cursor: pointer; padding: 0 2px; flex-shrink: 0; line-height: 1; | |
| } | |
| .ct-toast-dismiss:hover { color: #fff; } | |
| .ct-toast-spinner { | |
| width: 18px; height: 18px; | |
| border: 2px solid rgba(255,255,255,.3); | |
| border-top-color: #fff; | |
| border-radius: 50%; | |
| animation: ctSpin 0.7s linear infinite; | |
| flex-shrink: 0; | |
| } | |
| @keyframes ctToastIn { to { opacity: 1; transform: translateY(0) scale(1); } } | |
| @keyframes ctToastOut { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(12px) scale(0.95); } } | |
| @keyframes ctSpin { to { transform: rotate(360deg); } } | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| function getToastContainer() { | |
| let c = document.getElementById(TOAST_CONTAINER_ID); | |
| if (!c) { c = document.createElement('div'); c.id = TOAST_CONTAINER_ID; document.body.appendChild(c); } | |
| return c; | |
| } | |
| function showToast({ type = 'info', icon = 'ℹ️', message, detail, duration = 4000 }) { | |
| injectToastStyles(); | |
| const container = getToastContainer(); | |
| const toast = document.createElement('div'); | |
| toast.className = `ct-toast ct-toast-${type}`; | |
| const iconEl = document.createElement('span'); iconEl.className = 'ct-toast-icon'; | |
| const bodyEl = document.createElement('div'); bodyEl.className = 'ct-toast-body'; | |
| const dismissBtn = document.createElement('button'); | |
| dismissBtn.className = 'ct-toast-dismiss'; dismissBtn.textContent = '✕'; dismissBtn.title = 'Dismiss'; | |
| function render({ type: t, icon: i, message: m, detail: d } = {}) { | |
| if (t) toast.className = `ct-toast ct-toast-${t}`; | |
| if (t === 'progress') { | |
| iconEl.innerHTML = ''; const s = document.createElement('div'); s.className = 'ct-toast-spinner'; iconEl.appendChild(s); | |
| } else { iconEl.textContent = i ?? icon; } | |
| bodyEl.innerHTML = ''; | |
| bodyEl.appendChild(document.createTextNode(m ?? message)); | |
| if (d) { const sm = document.createElement('small'); sm.textContent = d; bodyEl.appendChild(sm); } | |
| } | |
| render({ type, icon, message, detail }); | |
| toast.append(iconEl, bodyEl, dismissBtn); | |
| container.appendChild(toast); | |
| let timer = null; | |
| function dismiss() { | |
| if (timer) clearTimeout(timer); | |
| toast.classList.add('ct-toast-out'); | |
| toast.addEventListener('animationend', () => toast.remove(), { once: true }); | |
| } | |
| dismissBtn.addEventListener('click', dismiss); | |
| if (duration > 0) timer = setTimeout(dismiss, duration); | |
| return { el: toast, dismiss, update(opts) { | |
| if (timer) { clearTimeout(timer); timer = null; } | |
| render(opts); | |
| if (opts.duration && opts.duration > 0) timer = setTimeout(dismiss, opts.duration); | |
| }}; | |
| } | |
| // ── Shared: React fiber walker ── | |
| function getDocFromButton(btn) { | |
| const fk = Object.keys(btn).find(k => k.startsWith('__reactFiber')); | |
| if (!fk) return null; | |
| let node = btn[fk]; | |
| for (let d = 0; node && d < 15; d++, node = node.return) { | |
| if (node.memoizedProps?.doc?.file_name) return node.memoizedProps.doc; | |
| } | |
| return null; | |
| } | |
| function getAllDocs() { | |
| const seen = new Set(); const docs = []; | |
| for (const btn of document.querySelectorAll('button')) { | |
| const doc = getDocFromButton(btn); | |
| if (doc?.uuid && !seen.has(doc.uuid)) { seen.add(doc.uuid); docs.push(doc); } | |
| } | |
| return docs; | |
| } | |
| // ── Extract project instructions for CLAUDE.md ── | |
| function getInstructions() { | |
| const heading = Array.from(document.querySelectorAll('h1, h2, h3, h4, [class*="heading"]')) | |
| .find(el => el.textContent.trim() === 'Instructions'); | |
| if (!heading) return null; | |
| let el = heading; | |
| for (let climb = 0; el && climb < 8; climb++, el = el.parentElement) { | |
| const fk = Object.keys(el).find(k => k.startsWith('__reactFiber')); | |
| if (!fk) continue; | |
| let node = el[fk]; | |
| for (let d = 0; node && d < 25; d++, node = node.return) { | |
| const p = node.memoizedProps; | |
| if (p?.project?.prompt_template) return p.project.prompt_template; | |
| if (p?.instructions && typeof p.instructions === 'string') return p.instructions; | |
| } | |
| } | |
| const section = heading.closest('[class*="flex-col"]'); | |
| const textEl = section?.querySelector('[class*="prose"], [class*="instruction"], [class*="text"]') | |
| || section?.querySelector('div > div'); | |
| return textEl?.innerText || null; | |
| } | |
| // ═══════════════════════════════════════════════════════════ | |
| // ── 1. Backup all project knowledge ── | |
| // ═══════════════════════════════════════════════════════════ | |
| async function backupProject() { | |
| const docs = getAllDocs(); | |
| if (!docs.length) { | |
| showToast({ type: 'warning', icon: '📭', message: 'No project docs found.', duration: 3000 }); | |
| return; | |
| } | |
| const LOG_ID = '__ct-backup-log'; | |
| document.getElementById(LOG_ID)?.remove(); | |
| const logPanel = document.createElement('div'); | |
| logPanel.id = LOG_ID; | |
| Object.assign(logPanel.style, { | |
| position: 'fixed', bottom: '80px', right: '20px', zIndex: '100001', | |
| width: '380px', maxHeight: '50vh', overflowY: 'auto', | |
| background: '#1e1e1e', color: '#d4d4d4', fontFamily: 'monospace', | |
| fontSize: '11px', lineHeight: '1.5', padding: '12px 14px', | |
| borderRadius: '10px', boxShadow: '0 8px 32px rgba(0,0,0,.4)', | |
| }); | |
| document.body.appendChild(logPanel); | |
| const startTime = performance.now(); | |
| function log(msg, color = '#d4d4d4') { | |
| const elapsed = ((performance.now() - startTime) / 1000).toFixed(1); | |
| const line = document.createElement('div'); | |
| line.style.color = color; | |
| line.textContent = `[${elapsed}s] ${msg}`; | |
| logPanel.appendChild(line); | |
| logPanel.scrollTop = logPanel.scrollHeight; | |
| } | |
| function logError(msg) { log(`❌ ${msg}`, '#f44'); } | |
| function logOk(msg) { log(`✅ ${msg}`, '#4ec944'); } | |
| function logInfo(msg) { log(`ℹ️ ${msg}`, '#6cb6ff'); } | |
| function logWarn(msg) { log(`⚠️ ${msg}`, '#e5a633'); } | |
| const closeBtn = document.createElement('button'); | |
| Object.assign(closeBtn.style, { | |
| position: 'absolute', top: '6px', right: '8px', background: 'none', | |
| border: 'none', color: '#888', fontSize: '14px', cursor: 'pointer', | |
| }); | |
| closeBtn.textContent = '✕'; | |
| closeBtn.onclick = () => logPanel.remove(); | |
| logPanel.appendChild(closeBtn); | |
| log(`Backup started — ${docs.length} docs found`); | |
| const tick = () => new Promise(r => setTimeout(r, 0)); | |
| try { | |
| logInfo('Loading JSZip…'); | |
| await tick(); | |
| const PageJSZip = await loadPageJSZip(); | |
| logOk('JSZip ready'); | |
| logInfo('Creating ZIP…'); | |
| await tick(); | |
| const zip = new PageJSZip(); | |
| const count = {}; | |
| for (let i = 0; i < docs.length; i++) { | |
| const doc = docs[i]; | |
| const name = doc.file_name; | |
| count[name] = (count[name] || 0) + 1; | |
| const ext = name.lastIndexOf('.'); | |
| const zipName = count[name] > 1 | |
| ? (ext > 0 ? `${name.slice(0, ext)}_${count[name]}${name.slice(ext)}` : `${name}_${count[name]}`) | |
| : name; | |
| zip.file(zipName, doc.content ?? ''); | |
| log(` + ${zipName} (${(doc.content ?? '').length} chars)`); | |
| if (i % 10 === 9) await tick(); | |
| } | |
| logInfo('Looking for instructions…'); | |
| await tick(); | |
| const instructions = getInstructions(); | |
| if (instructions) { | |
| zip.file('CLAUDE.md', instructions); | |
| logOk(`CLAUDE.md added (${instructions.length} chars)`); | |
| } else { | |
| logWarn('No instructions — skipping CLAUDE.md'); | |
| } | |
| logInfo('Generating ZIP…'); | |
| await tick(); | |
| const blob = await zip.generateAsync( | |
| { type: 'blob', compression: 'STORE' }, | |
| meta => { if (meta.percent && meta.percent % 25 < 1) log(` ${meta.percent.toFixed(0)}%`); } | |
| ); | |
| logOk(`Blob ready — ${(blob.size / 1024).toFixed(1)} KB`); | |
| const title = document.title.replace(/[^\w\s-]/g, '').trim(); | |
| const date = new Date().toISOString().slice(0, 10); | |
| const fileName = `${title}-backup-${date}.zip`; | |
| const a = Object.assign(document.createElement('a'), { | |
| href: URL.createObjectURL(blob), download: fileName, | |
| }); | |
| document.body.appendChild(a); a.click(); a.remove(); | |
| URL.revokeObjectURL(a.href); | |
| logOk(`Download: ${fileName}`); | |
| const extra = instructions ? ' + CLAUDE.md' : ''; | |
| showToast({ | |
| type: 'success', icon: '✅', message: 'Backup complete!', | |
| detail: `${docs.length} files${extra} · ${(blob.size / (1024 * 1024)).toFixed(1)} MB`, | |
| duration: 6000, | |
| }); | |
| setTimeout(() => { | |
| if (logPanel.parentElement) { | |
| logPanel.style.transition = 'opacity 0.5s'; | |
| logPanel.style.opacity = '0'; | |
| setTimeout(() => logPanel.remove(), 500); | |
| } | |
| }, 8000); | |
| } catch (err) { | |
| logError(`FAILED: ${err.message}`); | |
| showToast({ type: 'error', icon: '❌', message: 'Backup failed', detail: err.message, duration: 8000 }); | |
| } | |
| } | |
| // ── Robust filename parser ── | |
| function parseFileNames(raw) { | |
| return raw.split(/[,\n]+/).map(s => s.trim()).map(s => s.replace(/^["']+|["']+$/g, '')) | |
| .map(s => s.replace(/^\d+\.\s*/, '')).map(s => s.replace(/^[-•*]\s*/, '')).filter(Boolean); | |
| } | |
| // ═══════════════════════════════════════════════════════════ | |
| // ── 2. Multi-select files by name ── | |
| // ═══════════════════════════════════════════════════════════ | |
| function selectFilesByName(names) { | |
| const targetSet = new Set(names); | |
| const results = { checked: [], alreadyChecked: [], notFound: [] }; | |
| const found = new Set(); | |
| for (const btn of document.querySelectorAll('button')) { | |
| const doc = getDocFromButton(btn); | |
| if (!doc?.file_name || !targetSet.has(doc.file_name)) continue; | |
| found.add(doc.file_name); | |
| const cb = Array.from(btn.querySelectorAll('input[type="checkbox"]')) | |
| .find(c => !c.closest('.delete-checkbox-overlay')); | |
| if (!cb) continue; | |
| if (cb.checked) { results.alreadyChecked.push(doc.file_name); } | |
| else { cb.scrollIntoView({ block: 'center' }); cb.click(); results.checked.push(doc.file_name); } | |
| } | |
| for (const n of names) { if (!found.has(n)) results.notFound.push(n); } | |
| return results; | |
| } | |
| function promptAndSelect() { | |
| const input = prompt('Enter file names to select\n(comma-separated, one per line, or mixed):\n\n' + | |
| 'Example:\nsession-33-handoff.md, session-34-handoff.md\ndoc-c-draft-v0_4.md'); | |
| if (!input) return; | |
| const names = parseFileNames(input); | |
| if (!names.length) return alert('No valid file names found.'); | |
| const r = selectFilesByName(names); | |
| const lines = []; | |
| if (r.checked.length) lines.push(`✅ ${r.checked.length} newly selected`); | |
| if (r.alreadyChecked.length) lines.push(`⏭ ${r.alreadyChecked.length} already selected`); | |
| if (r.notFound.length) lines.push(`❌ ${r.notFound.length} not found:\n ${r.notFound.join('\n ')}`); | |
| alert(lines.join('\n')); | |
| return r; | |
| } | |
| // ═══════════════════════════════════════════════════════════ | |
| // ── 3. List all files → clipboard ── | |
| // ═══════════════════════════════════════════════════════════ | |
| function listFiles() { | |
| const docs = getAllDocs(); | |
| GM_setClipboard(docs.map(d => d.file_name).join('\n')); | |
| showToast({ type: 'success', icon: '📋', message: `${docs.length} file names copied`, duration: 3000 }); | |
| } | |
| // ═══════════════════════════════════════════════════════════ | |
| // ── 4. Highlight Duplicates — KEEP / OLD ── | |
| // ═══════════════════════════════════════════════════════════ | |
| // | |
| // Design principle: | |
| // DOM 순서 첫 번째 = 페이지 최상단 = 가장 최근 추가 → KEEP (녹색) | |
| // 나머지 = 이전 버전 → OLD (적색, 삭제 후보) | |
| // | |
| // 이렇게 하면 "어떤 걸 지워야 하지?" 고민 없이 | |
| // 빨간 OLD만 삭제하면 됩니다. | |
| const DUP_STYLE_ID = '__ct-dup-styles'; | |
| const DUP_PANEL_ID = '__ct-dup-panel'; | |
| const DUP_ATTR = 'data-ct-duplicate'; | |
| const DUP_ROLE_ATTR = 'data-ct-dup-role'; // "keep" | "old" | |
| let dupEnabled = GM_getValue('dupHighlightEnabled', true); | |
| let panelExpanded = false; | |
| // ── Fingerprint ── | |
| let lastFileFingerprint = ''; | |
| let hasEverSeenFiles = false; | |
| function getFileFingerprint() { | |
| const ids = []; | |
| for (const btn of document.querySelectorAll('button')) { | |
| const doc = getDocFromButton(btn); | |
| if (doc?.file_name) ids.push(doc.uuid || doc.file_name); | |
| } | |
| return ids.sort().join('|'); | |
| } | |
| // ── Styles ── | |
| function injectDupStyles() { | |
| if (document.getElementById(DUP_STYLE_ID)) return; | |
| const style = document.createElement('style'); | |
| style.id = DUP_STYLE_ID; | |
| style.textContent = ` | |
| /* ─── OLD: deletion candidates ─── */ | |
| button[${DUP_ATTR}][${DUP_ROLE_ATTR}="old"] { | |
| outline: 2px solid #dc2626 !important; | |
| outline-offset: -2px; | |
| background: #fef2f2 !important; | |
| position: relative; | |
| } | |
| button[${DUP_ATTR}][${DUP_ROLE_ATTR}="old"]::after { | |
| content: 'OLD'; | |
| position: absolute; top: 4px; right: 4px; | |
| font-size: 9px; font-weight: 700; color: #fff; background: #dc2626; | |
| padding: 1px 5px; border-radius: 3px; line-height: 1.4; | |
| pointer-events: none; z-index: 2; | |
| } | |
| /* ─── KEEP: newest copy ─── */ | |
| button[${DUP_ATTR}][${DUP_ROLE_ATTR}="keep"] { | |
| outline: 2px solid #16a34a !important; | |
| outline-offset: -2px; | |
| position: relative; | |
| } | |
| button[${DUP_ATTR}][${DUP_ROLE_ATTR}="keep"]::after { | |
| content: 'KEEP'; | |
| position: absolute; top: 4px; right: 4px; | |
| font-size: 9px; font-weight: 700; color: #fff; background: #16a34a; | |
| padding: 1px 5px; border-radius: 3px; line-height: 1.4; | |
| pointer-events: none; z-index: 2; | |
| } | |
| /* ─── Panel ─── */ | |
| #${DUP_PANEL_ID} { | |
| margin: 8px 0 4px; | |
| border: 1px solid #e5e7eb; | |
| border-radius: 10px; | |
| background: #fafafa; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| overflow: hidden; | |
| transition: border-color 0.2s; | |
| } | |
| #${DUP_PANEL_ID}:hover { border-color: #d1d5db; } | |
| #${DUP_PANEL_ID} .ct-dp-bar { | |
| display: flex; align-items: center; gap: 8px; | |
| padding: 8px 12px; cursor: pointer; user-select: none; | |
| } | |
| #${DUP_PANEL_ID} .ct-dp-bar:hover { background: #f3f4f6; } | |
| #${DUP_PANEL_ID} .ct-dp-chevron { | |
| font-size: 11px; color: #9ca3af; transition: transform 0.2s ease; | |
| flex-shrink: 0; width: 16px; text-align: center; | |
| } | |
| #${DUP_PANEL_ID}.ct-dp-expanded .ct-dp-chevron { transform: rotate(90deg); } | |
| #${DUP_PANEL_ID} .ct-dp-icon { font-size: 14px; flex-shrink: 0; } | |
| #${DUP_PANEL_ID} .ct-dp-label { font-size: 12px; font-weight: 600; color: #6b7280; flex: 1; } | |
| #${DUP_PANEL_ID} .ct-dp-count { | |
| font-size: 11px; font-weight: 600; color: #fff; background: #dc2626; | |
| padding: 1px 7px; border-radius: 9px; line-height: 1.5; flex-shrink: 0; | |
| } | |
| #${DUP_PANEL_ID} .ct-dp-body { | |
| max-height: 0; overflow: hidden; transition: max-height 0.3s ease; | |
| } | |
| #${DUP_PANEL_ID}.ct-dp-expanded .ct-dp-body { | |
| max-height: 600px; overflow-y: auto; | |
| } | |
| #${DUP_PANEL_ID} .ct-dp-inner { | |
| padding: 0 12px 10px; border-top: 1px solid #e5e7eb; | |
| } | |
| #${DUP_PANEL_ID} .ct-dp-group { margin-top: 10px; } | |
| #${DUP_PANEL_ID} .ct-dp-group-name { | |
| font-weight: 700; font-size: 12px; color: #374151; margin-bottom: 3px; | |
| } | |
| #${DUP_PANEL_ID} .ct-dp-entry { | |
| display: flex; align-items: center; gap: 6px; | |
| font-size: 11px; color: #777; padding: 3px 0 3px 10px; | |
| cursor: pointer; border-radius: 4px; | |
| } | |
| #${DUP_PANEL_ID} .ct-dp-entry:hover { background: #f3f4f6; color: #555; } | |
| #${DUP_PANEL_ID} .ct-dp-role-badge { | |
| font-size: 9px; font-weight: 700; color: #fff; | |
| padding: 0 4px; border-radius: 3px; line-height: 1.6; flex-shrink: 0; | |
| } | |
| #${DUP_PANEL_ID} .ct-dp-role-keep { background: #16a34a; } | |
| #${DUP_PANEL_ID} .ct-dp-role-old { background: #dc2626; } | |
| #${DUP_PANEL_ID} .ct-dp-lines { white-space: nowrap; } | |
| #${DUP_PANEL_ID} .ct-dp-uuid { font-family: monospace; font-size: 10px; color: #bbb; white-space: nowrap; } | |
| #${DUP_PANEL_ID} .ct-dp-summary { | |
| margin-top: 10px; padding-top: 8px; | |
| border-top: 1px solid #e5e7eb; font-size: 11px; color: #999; | |
| } | |
| #${DUP_PANEL_ID} .ct-dp-hint { | |
| margin-top: 4px; font-size: 10px; color: #9ca3af; font-style: italic; | |
| } | |
| #${DUP_PANEL_ID}.ct-dp-hidden { display: none; } | |
| /* ─── Cleanup action bar ─── */ | |
| #${DUP_PANEL_ID} .ct-dp-cleanup { | |
| margin-top: 10px; padding-top: 10px; | |
| border-top: 1px solid #e5e7eb; | |
| display: flex; align-items: center; gap: 10px; flex-wrap: wrap; | |
| } | |
| #${DUP_PANEL_ID} .ct-dp-cleanup-cb { | |
| display: flex; align-items: center; gap: 5px; | |
| font-size: 11px; color: #6b7280; cursor: pointer; user-select: none; | |
| } | |
| #${DUP_PANEL_ID} .ct-dp-cleanup-cb input { | |
| accent-color: #16a34a; cursor: pointer; | |
| } | |
| #${DUP_PANEL_ID} .ct-dp-select-btn { | |
| display: inline-flex; align-items: center; gap: 4px; | |
| padding: 5px 10px; border: 1px solid #dc2626; border-radius: 6px; | |
| background: #fff; color: #dc2626; font-size: 11px; font-weight: 600; | |
| cursor: pointer; transition: all 0.15s; line-height: 1.4; | |
| } | |
| #${DUP_PANEL_ID} .ct-dp-select-btn:hover { | |
| background: #fef2f2; border-color: #b91c1c; | |
| } | |
| #${DUP_PANEL_ID} .ct-dp-select-btn:disabled { | |
| opacity: 0.5; cursor: wait; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| function removeDupStyles() { document.getElementById(DUP_STYLE_ID)?.remove(); } | |
| // ── Duplicate detection ── | |
| // DOM-order first = topmost = newest → role:"keep" | |
| // Rest → role:"old" | |
| function findDuplicates() { | |
| const byName = {}; | |
| for (const btn of document.querySelectorAll('button')) { | |
| const doc = getDocFromButton(btn); | |
| if (!doc?.file_name) continue; | |
| if (!byName[doc.file_name]) byName[doc.file_name] = []; | |
| byName[doc.file_name].push({ doc, btn }); | |
| } | |
| const dupes = {}; | |
| for (const [name, entries] of Object.entries(byName)) { | |
| if (entries.length <= 1) continue; | |
| dupes[name] = entries.map((entry, idx) => ({ | |
| ...entry, | |
| role: idx === 0 ? 'keep' : 'old', | |
| })); | |
| } | |
| return dupes; | |
| } | |
| // ── Select OLD files for removal (with optional backup) ── | |
| let backupBeforeCleanup = true; // persisted in checkbox state only | |
| async function selectOldForRemoval() { | |
| const dupes = findDuplicates(); | |
| const oldEntries = []; | |
| for (const [name, entries] of Object.entries(dupes)) { | |
| for (const entry of entries) { | |
| if (entry.role === 'old') oldEntries.push({ ...entry, fileName: name }); | |
| } | |
| } | |
| if (!oldEntries.length) { | |
| showToast({ type: 'info', icon: 'ℹ️', message: 'No OLD duplicates to remove.', duration: 3000 }); | |
| return; | |
| } | |
| // ── confirm() with file list ── | |
| const list = oldEntries.map(e => ` ✕ ${e.fileName}`).join('\n'); | |
| const backupNote = backupBeforeCleanup | |
| ? '\n⚠️ A backup will be created first.\n' | |
| : '\n⚠️ No backup will be made!\n'; | |
| const ok = confirm( | |
| `Select ${oldEntries.length} OLD file${oldEntries.length !== 1 ? 's' : ''} for removal:\n\n` + | |
| list + '\n' + | |
| backupNote + | |
| `The newest copy (KEEP) of each will be preserved.\nProceed?` | |
| ); | |
| if (!ok) return; | |
| // ── Optional backup ── | |
| if (backupBeforeCleanup) { | |
| const toast = showToast({ | |
| type: 'progress', message: 'Backing up project before cleanup…', duration: 0, | |
| }); | |
| try { | |
| await backupProject(); | |
| toast.update({ | |
| type: 'success', icon: '✅', | |
| message: 'Backup complete — selecting OLD files…', | |
| duration: 2000, | |
| }); | |
| } catch (err) { | |
| toast.update({ | |
| type: 'error', icon: '❌', | |
| message: 'Backup failed — aborting cleanup', | |
| detail: err?.message, duration: 6000, | |
| }); | |
| return; // abort if backup fails | |
| } | |
| // Small delay so user sees the backup toast | |
| await new Promise(r => setTimeout(r, 800)); | |
| } | |
| // ── Click checkboxes on OLD buttons ── | |
| let checked = 0, failed = 0; | |
| for (const { btn, fileName } of oldEntries) { | |
| const cb = Array.from(btn.querySelectorAll('input[type="checkbox"]')) | |
| .find(c => !c.closest('.delete-checkbox-overlay')); | |
| if (!cb) { failed++; continue; } | |
| if (cb.checked) continue; // already checked | |
| cb.scrollIntoView({ block: 'center' }); | |
| cb.click(); | |
| checked++; | |
| await new Promise(r => setTimeout(r, 150)); | |
| } | |
| const detail = backupBeforeCleanup ? 'Backup saved. Review checked files, then delete.' : 'Review checked files carefully, then delete.'; | |
| showToast({ | |
| type: checked > 0 ? 'warning' : 'info', | |
| icon: checked > 0 ? '☑️' : 'ℹ️', | |
| message: checked > 0 | |
| ? `${checked} OLD file${checked !== 1 ? 's' : ''} checked for removal` | |
| : 'No new files to check', | |
| detail: failed > 0 ? `${failed} could not be selected. ${detail}` : detail, | |
| duration: 6000, | |
| }); | |
| } | |
| // ── Apply highlights ── | |
| function applyHighlights() { | |
| document.querySelectorAll(`button[${DUP_ATTR}]`).forEach(b => { | |
| b.removeAttribute(DUP_ATTR); | |
| b.removeAttribute(DUP_ROLE_ATTR); | |
| }); | |
| if (!dupEnabled) return {}; | |
| const dupes = findDuplicates(); | |
| for (const [, entries] of Object.entries(dupes)) { | |
| for (const { btn, role } of entries) { | |
| btn.setAttribute(DUP_ATTR, 'true'); | |
| btn.setAttribute(DUP_ROLE_ATTR, role); | |
| } | |
| } | |
| return dupes; | |
| } | |
| // ── Files section container ── | |
| function getFilesSectionContainer() { | |
| const h = Array.from(document.querySelectorAll('h3')).find(el => el.textContent.trim() === 'Files'); | |
| return h?.parentElement?.parentElement || null; | |
| } | |
| // ── Panel rendering ── | |
| let isRendering = false; | |
| function renderDupPanel() { | |
| if (isRendering) return; | |
| isRendering = true; | |
| try { | |
| const dupes = applyHighlights(); | |
| const names = Object.keys(dupes).sort(); | |
| const oldFiles = names.reduce((sum, n) => sum + dupes[n].filter(e => e.role === 'old').length, 0); | |
| const totalFiles = names.reduce((sum, n) => sum + dupes[n].length, 0); | |
| let panel = document.getElementById(DUP_PANEL_ID); | |
| if (!panel) { | |
| panel = document.createElement('div'); | |
| panel.id = DUP_PANEL_ID; | |
| const bar = document.createElement('div'); | |
| bar.className = 'ct-dp-bar'; | |
| const chevron = document.createElement('span'); chevron.className = 'ct-dp-chevron'; chevron.textContent = '▶'; | |
| const icon = document.createElement('span'); icon.className = 'ct-dp-icon'; icon.textContent = '📋'; | |
| const label = document.createElement('span'); label.className = 'ct-dp-label'; | |
| const count = document.createElement('span'); count.className = 'ct-dp-count'; | |
| bar.append(chevron, icon, label, count); | |
| bar.addEventListener('click', () => { | |
| panelExpanded = !panelExpanded; | |
| panel.classList.toggle('ct-dp-expanded', panelExpanded); | |
| }); | |
| const body = document.createElement('div'); body.className = 'ct-dp-body'; | |
| const inner = document.createElement('div'); inner.className = 'ct-dp-inner'; | |
| body.appendChild(inner); | |
| panel.append(bar, body); | |
| const section = getFilesSectionContainer(); | |
| if (section) { section.appendChild(panel); } | |
| else { document.querySelector('main')?.appendChild(panel); } | |
| } | |
| const label = panel.querySelector('.ct-dp-label'); | |
| const countBadge = panel.querySelector('.ct-dp-count'); | |
| const inner = panel.querySelector('.ct-dp-inner'); | |
| if (names.length === 0) { | |
| panel.classList.add('ct-dp-hidden'); | |
| return; | |
| } | |
| panel.classList.remove('ct-dp-hidden'); | |
| label.textContent = 'Duplicates found'; | |
| countBadge.textContent = `${oldFiles} removable`; | |
| panel.classList.toggle('ct-dp-expanded', panelExpanded); | |
| inner.innerHTML = ''; | |
| for (const name of names) { | |
| const entries = dupes[name]; | |
| const group = document.createElement('div'); group.className = 'ct-dp-group'; | |
| const gName = document.createElement('div'); gName.className = 'ct-dp-group-name'; | |
| gName.textContent = `${name} (×${entries.length})`; | |
| group.appendChild(gName); | |
| for (const { doc, btn, role } of entries) { | |
| const entry = document.createElement('div'); entry.className = 'ct-dp-entry'; | |
| const badge = document.createElement('span'); | |
| badge.className = `ct-dp-role-badge ct-dp-role-${role}`; | |
| badge.textContent = role === 'keep' ? 'KEEP' : 'OLD'; | |
| const linesSpan = document.createElement('span'); linesSpan.className = 'ct-dp-lines'; | |
| linesSpan.textContent = `${doc.content ? doc.content.split('\n').length : '?'} lines`; | |
| const uuidSpan = document.createElement('span'); uuidSpan.className = 'ct-dp-uuid'; | |
| uuidSpan.textContent = doc.uuid ? doc.uuid.slice(0, 8) + '…' : ''; | |
| entry.append(badge, linesSpan, uuidSpan); | |
| entry.addEventListener('click', () => { | |
| btn.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| const color = role === 'keep' ? '#16a34a' : '#dc2626'; | |
| btn.style.transition = 'box-shadow 0.2s'; | |
| btn.style.boxShadow = `0 0 0 4px ${color}`; | |
| setTimeout(() => { btn.style.boxShadow = ''; }, 1500); | |
| }); | |
| group.appendChild(entry); | |
| } | |
| inner.appendChild(group); | |
| } | |
| const summary = document.createElement('div'); summary.className = 'ct-dp-summary'; | |
| summary.textContent = | |
| `${totalFiles} files in ${names.length} group${names.length !== 1 ? 's' : ''}. ` + | |
| `${oldFiles} old cop${oldFiles !== 1 ? 'ies' : 'y'} safe to remove.`; | |
| inner.appendChild(summary); | |
| const hint = document.createElement('div'); hint.className = 'ct-dp-hint'; | |
| hint.textContent = 'KEEP = topmost (newest). OLD = older copies, safe to delete.'; | |
| inner.appendChild(hint); | |
| // ── Cleanup action bar ── | |
| const cleanup = document.createElement('div'); cleanup.className = 'ct-dp-cleanup'; | |
| // Backup checkbox | |
| const cbLabel = document.createElement('label'); cbLabel.className = 'ct-dp-cleanup-cb'; | |
| const cbInput = document.createElement('input'); | |
| cbInput.type = 'checkbox'; | |
| cbInput.checked = backupBeforeCleanup; | |
| cbInput.addEventListener('change', () => { backupBeforeCleanup = cbInput.checked; }); | |
| cbLabel.append(cbInput, document.createTextNode(' Backup first')); | |
| cleanup.appendChild(cbLabel); | |
| // Select OLD button | |
| const selectBtn = document.createElement('button'); selectBtn.className = 'ct-dp-select-btn'; | |
| selectBtn.textContent = `☑ Select ${oldFiles} OLD`; | |
| selectBtn.addEventListener('click', async (e) => { | |
| e.stopPropagation(); // don't toggle panel | |
| selectBtn.disabled = true; | |
| selectBtn.textContent = '⏳ Working…'; | |
| try { | |
| await selectOldForRemoval(); | |
| } finally { | |
| selectBtn.disabled = false; | |
| selectBtn.textContent = `☑ Select ${oldFiles} OLD`; | |
| } | |
| }); | |
| cleanup.appendChild(selectBtn); | |
| inner.appendChild(cleanup); | |
| } finally { | |
| isRendering = false; | |
| } | |
| } | |
| function removeDupPanel() { document.getElementById(DUP_PANEL_ID)?.remove(); } | |
| function clearHighlights() { | |
| document.querySelectorAll(`button[${DUP_ATTR}]`).forEach(b => { | |
| b.removeAttribute(DUP_ATTR); | |
| b.removeAttribute(DUP_ROLE_ATTR); | |
| }); | |
| } | |
| // ═══════════════════════════════════════════════════════════ | |
| // ── Observer — race-condition hardened ── | |
| // ═══════════════════════════════════════════════════════════ | |
| let dupObserver = null; | |
| let dupPollInterval = null; | |
| let observedTarget = null; | |
| let initRetryCount = 0; | |
| const MAX_INIT_RETRIES = 20; // 20 × 500ms = 10s | |
| let initRetryTimer = null; | |
| function getSidebarCard() { | |
| const section = getFilesSectionContainer(); | |
| return section?.parentElement || null; | |
| } | |
| function onFileListChange() { | |
| if (!dupEnabled || isRendering) return; | |
| const fp = getFileFingerprint(); | |
| // Empty fingerprint = files not loaded yet — don't cache | |
| if (!fp) return; | |
| if (!hasEverSeenFiles) hasEverSeenFiles = true; | |
| if (fp === lastFileFingerprint) return; | |
| lastFileFingerprint = fp; | |
| renderDupPanel(); | |
| } | |
| function attachObserver() { | |
| if (dupObserver) { dupObserver.disconnect(); dupObserver = null; } | |
| const card = getSidebarCard(); | |
| if (!card) return false; | |
| observedTarget = card; | |
| let debounceTimer = null; | |
| dupObserver = new MutationObserver((mutations) => { | |
| const onlyOurs = mutations.every(m => | |
| m.target.id === DUP_PANEL_ID || | |
| m.target.closest?.(`#${DUP_PANEL_ID}`) | |
| ); | |
| if (onlyOurs) return; | |
| clearTimeout(debounceTimer); | |
| debounceTimer = setTimeout(onFileListChange, 200); | |
| }); | |
| dupObserver.observe(card, { childList: true, subtree: true }); | |
| return true; | |
| } | |
| function startDupObserver() { | |
| attachObserver(); | |
| if (dupPollInterval) clearInterval(dupPollInterval); | |
| dupPollInterval = setInterval(() => { | |
| if (!dupEnabled) return; | |
| const card = getSidebarCard(); | |
| if (card && card !== observedTarget) { | |
| attachObserver(); | |
| onFileListChange(); | |
| return; | |
| } | |
| if (!document.getElementById(DUP_PANEL_ID)) { | |
| lastFileFingerprint = ''; | |
| onFileListChange(); | |
| } | |
| }, 2000); | |
| } | |
| function stopDupObserver() { | |
| if (dupObserver) { dupObserver.disconnect(); dupObserver = null; observedTarget = null; } | |
| if (dupPollInterval) { clearInterval(dupPollInterval); dupPollInterval = null; } | |
| if (initRetryTimer) { clearTimeout(initRetryTimer); initRetryTimer = null; } | |
| } | |
| // ── Init with retry ── | |
| function initDupHighlight() { | |
| if (!dupEnabled) return; | |
| injectDupStyles(); | |
| const fp = getFileFingerprint(); | |
| if (!fp && initRetryCount < MAX_INIT_RETRIES) { | |
| // Files not loaded yet → retry in 500ms | |
| initRetryCount++; | |
| initRetryTimer = setTimeout(initDupHighlight, 500); | |
| return; | |
| } | |
| if (fp) { | |
| hasEverSeenFiles = true; | |
| lastFileFingerprint = ''; // force first render | |
| } | |
| renderDupPanel(); | |
| startDupObserver(); | |
| } | |
| // ── Toggle ── | |
| function toggleDupHighlight() { | |
| dupEnabled = !dupEnabled; | |
| GM_setValue('dupHighlightEnabled', dupEnabled); | |
| if (dupEnabled) { | |
| initRetryCount = 0; | |
| hasEverSeenFiles = false; | |
| lastFileFingerprint = ''; | |
| initDupHighlight(); | |
| } else { | |
| clearHighlights(); | |
| removeDupPanel(); | |
| removeDupStyles(); | |
| stopDupObserver(); | |
| lastFileFingerprint = ''; | |
| hasEverSeenFiles = false; | |
| } | |
| showToast({ | |
| type: dupEnabled ? 'info' : 'warning', | |
| icon: dupEnabled ? '🔍' : '🔇', | |
| message: `Duplicate highlight: ${dupEnabled ? 'ON' : 'OFF'}`, | |
| duration: 2500 | |
| }); | |
| } | |
| function showDuplicatesPanel() { | |
| if (!dupEnabled) { | |
| dupEnabled = true; | |
| GM_setValue('dupHighlightEnabled', true); | |
| injectDupStyles(); | |
| startDupObserver(); | |
| } | |
| panelExpanded = true; | |
| lastFileFingerprint = ''; | |
| renderDupPanel(); | |
| document.getElementById(DUP_PANEL_ID)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| } | |
| // ── Kick off ── | |
| setTimeout(initDupHighlight, 800); | |
| // ── Menu commands ── | |
| GM_registerMenuCommand('📦 Backup Project Knowledge', backupProject); | |
| GM_registerMenuCommand('☑️ Select Files by Name', promptAndSelect); | |
| GM_registerMenuCommand('📋 List All Files → Clipboard', listFiles); | |
| GM_registerMenuCommand(`🔍 Toggle Duplicate Highlight (${dupEnabled ? 'ON' : 'OFF'})`, toggleDupHighlight); | |
| GM_registerMenuCommand('⚠️ Show Duplicates Panel', showDuplicatesPanel); | |
| // ── Expose ── | |
| unsafeWindow.__claudeTools = { | |
| backupProject, selectFilesByName, parseFileNames, listFiles, | |
| getAllDocs, getInstructions, findDuplicates, showDuplicatesPanel, | |
| toggleDupHighlight, applyHighlights, renderDupPanel, showToast, | |
| selectOldForRemoval | |
| }; | |
| console.log('🔧 Claude Project Tools v1.7 loaded.'); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment