diff --git a/README.md b/README.md index 10911d6..3faebea 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,8 @@ This extension provides several advantages over the official Claude.ai data expo 4. Click "Load unpacked" 5. Select the `dist/chrome/` directory +📖 **See [CHROME_INSTALL.md](./docs/CHROME_INSTALL.md) for detailed Chrome installation instructions and troubleshooting** + ### Configure Your Organization ID After installation (either browser): @@ -460,6 +462,17 @@ Be ready to explain why each permission is needed: - Some special content types (like artifacts) may not export perfectly - Rate limiting: The extension processes conversations in small batches to avoid overwhelming the API +## Documentation + +Additional documentation is available in the [`docs/`](./docs/) folder: + +- **[EXPORT_OPTIONS.md](./docs/EXPORT_OPTIONS.md)** - Guide to the three different export methods +- **[TROUBLESHOOTING.md](./docs/TROUBLESHOOTING.md)** - Step-by-step troubleshooting guide +- **[CONTENT_SCRIPT_FIX.md](./docs/CONTENT_SCRIPT_FIX.md)** - Technical details about the content script build solution +- **[CONNECTION_ERROR_FIX.md](./docs/CONNECTION_ERROR_FIX.md)** - Understanding and fixing connection errors +- **[TEST_SETUP_SUMMARY.md](./docs/TEST_SETUP_SUMMARY.md)** - Test setup and coverage information +- **[CLAUDE.md](./docs/CLAUDE.md)** - Design rationale and development notes + ## Contributing Feel free to submit issues or pull requests if you find bugs or have suggestions for improvements! diff --git a/build-chrome.sh b/build-chrome.sh new file mode 100755 index 0000000..d3bbc20 --- /dev/null +++ b/build-chrome.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Build script for Chrome extension +# This script builds the content script separately as an IIFE to avoid +# the broken dynamic import loader that the vite-plugin-web-extension creates + +set -e + +echo "Building Chrome extension..." + +# Step 1: Build content script separately as IIFE +echo "Step 1: Building content script..." +npx vite build --config vite.content-chrome.config.ts + +# Step 2: Build main extension with vite-plugin-web-extension +echo "Step 2: Building main extension..." +BROWSER=chrome npx vite build --mode chrome + +# Step 3: Copy the IIFE content script over the stub +echo "Step 3: Replacing content script stub with IIFE bundle..." +cp dist-temp-content-chrome/content.js dist/chrome/src/chrome/scripts/content.js + +# Step 3.5: Fix serviceWorker.js import path (make it relative) +echo "Step 3.5: Fixing serviceWorker.js import path..." +sed -i 's|"/src/|"./src/|g' dist/chrome/serviceWorker.js + +# Step 4: Copy icon files +echo "Step 4: Copying icon files..." +mkdir -p dist/chrome/src +cp src/icon16.png dist/chrome/src/ +cp src/icon48.png dist/chrome/src/ +cp src/icon128.png dist/chrome/src/ + +# Step 5: Copy popup header image +echo "Step 5: Copying popup header..." +mkdir -p dist/chrome/src/chrome/scripts +cp src/popup-header.png dist/chrome/src/chrome/scripts/ + +echo "Chrome build complete!" +echo "Load unpacked extension from: dist/chrome/" diff --git a/build-firefox.sh b/build-firefox.sh index 7345278..4a471da 100755 --- a/build-firefox.sh +++ b/build-firefox.sh @@ -4,18 +4,28 @@ set -e cd /home/jasonross/workspace/Claude-Conversation-Exporter-Firefox-TS echo "Building content script separately..." -npx vite build --config vite.content.config.ts +npx vite build --config vite.content-firefox.config.ts echo "Building main extension..." -npx tsc && npx vite build --mode firefox +npx vite build --mode firefox echo "Copying content script to dist..." -cp dist-temp-content/content.js dist/firefox/src/content.js -rm -rf dist-temp-content +cp dist-temp-content-firefox/content.js dist/firefox/src/firefox/scripts/content.js +rm -rf dist-temp-content-firefox + +echo "Copying icon files..." +mkdir -p dist/firefox/src +cp src/icon16.png dist/firefox/src/ +cp src/icon48.png dist/firefox/src/ +cp src/icon128.png dist/firefox/src/ + +echo "Copying popup header..." +mkdir -p dist/firefox/src/firefox/scripts +cp src/popup-header.png dist/firefox/src/firefox/scripts/ echo "" echo "Checking content script..." -CONTENT_SIZE=$(stat -c%s dist/firefox/src/content.js 2>/dev/null || stat -f%z dist/firefox/src/content.js) +CONTENT_SIZE=$(stat -c%s dist/firefox/src/firefox/scripts/content.js 2>/dev/null || stat -f%z dist/firefox/src/firefox/scripts/content.js) echo "Content script size: $CONTENT_SIZE bytes" if [ "$CONTENT_SIZE" -gt 1000 ]; then diff --git a/convert-to-chrome.sh b/convert-to-chrome.sh new file mode 100755 index 0000000..503cffd --- /dev/null +++ b/convert-to-chrome.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# Script to convert Firefox browser.* API calls to Chrome chrome.* API calls +# This creates Chrome-specific versions of the extension scripts + +set -e + +echo "Converting Firefox scripts to Chrome API..." + +# Files to convert +FILES=("popup.ts" "options.ts" "browse.ts" "content.ts" "background.ts") + +for file in "${FILES[@]}"; do + echo "Converting $file..." + + # Copy file to Chrome directory + cp "src/$file" "src/chrome/scripts/$file" + + # Convert browser.* calls to chrome.* + # This uses sed to replace browser API calls with chrome API calls + # and wraps Promise-based calls with callbacks + + sed -i 's/browser\.storage\.sync\.get/chrome.storage.sync.get/g' "src/chrome/scripts/$file" + sed -i 's/browser\.storage\.sync\.set/chrome.storage.sync.set/g' "src/chrome/scripts/$file" + sed -i 's/browser\.tabs\.query/chrome.tabs.query/g' "src/chrome/scripts/$file" + sed -i 's/browser\.tabs\.sendMessage/chrome.tabs.sendMessage/g' "src/chrome/scripts/$file" + sed -i 's/browser\.tabs\.create/chrome.tabs.create/g' "src/chrome/scripts/$file" + sed -i 's/browser\.runtime\.sendMessage/chrome.runtime.sendMessage/g' "src/chrome/scripts/$file" + sed -i 's/browser\.runtime\.getURL/chrome.runtime.getURL/g' "src/chrome/scripts/$file" + sed -i 's/browser\.runtime\.onMessage/chrome.runtime.onMessage/g' "src/chrome/scripts/$file" + + echo "✅ Converted $file" +done + +# Copy Firefox scripts to firefox directory +echo "Copying Firefox scripts..." +for file in "${FILES[@]}"; do + cp "src/$file" "src/firefox/scripts/$file" + echo "✅ Copied $file to Firefox directory" +done + +echo "" +echo "Conversion complete!" +echo "Chrome scripts: src/chrome/scripts/" +echo "Firefox scripts: src/firefox/scripts/" diff --git a/dist-temp-content-chrome/content.js b/dist-temp-content-chrome/content.js new file mode 100644 index 0000000..6c6c298 --- /dev/null +++ b/dist-temp-content-chrome/content.js @@ -0,0 +1,26 @@ +(function(){"use strict";const u=[{date:new Date("2024-01-01"),model:"claude-3-sonnet-20240229"},{date:new Date("2024-06-20"),model:"claude-3-5-sonnet-20240620"},{date:new Date("2024-10-22"),model:"claude-3-5-sonnet-20241022"},{date:new Date("2025-02-29"),model:"claude-3-7-sonnet-20250219"},{date:new Date("2025-05-14"),model:"claude-sonnet-4-20250514"},{date:new Date("2025-09-29"),model:"claude-sonnet-4-5-20250929"}];function d(n){if(n.model)return n.model;const r=new Date(n.created_at);for(let e=u.length-1;e>=0;e--){const o=u[e];if(o&&r>=o.date)return o.model}return u[0]?.model}function w(n){if(!n.chat_messages||!n.current_leaf_message_uuid)return[];const r=new Map;n.chat_messages.forEach(t=>{r.set(t.uuid,t)});const e=[];let o=n.current_leaf_message_uuid;for(;o&&r.has(o);){const t=r.get(o);if(!t||(e.unshift(t),o=t.parent_message_uuid,o&&!r.has(o)))break}return e}function p(n){let r="";if(n.content)for(const e of n.content)e.text&&(r+=e.text);else n.text&&(r=n.text);return r}function h(n,r){let e=`# ${n.name||"Untitled Conversation"} + +`;r&&(e+=`**Created:** ${new Date(n.created_at).toLocaleString()} +`,e+=`**Updated:** ${new Date(n.updated_at).toLocaleString()} +`,e+=`**Model:** ${d(n)} + +`,e+=`--- + +`);const o=w(n);for(const t of o){const a=t.sender==="human"?"**You**":"**Claude**";e+=`${a}: + +`;const s=p(t);s&&(e+=`${s} + +`),r&&t.created_at&&(e+=`*${new Date(t.created_at).toLocaleString()}* + +`),e+=`--- + +`}return e}function v(n,r){let e="";r&&(e+=`${n.name||"Untitled Conversation"} +`,e+=`Created: ${new Date(n.created_at).toLocaleString()} +`,e+=`Updated: ${new Date(n.updated_at).toLocaleString()} +`,e+=`Model: ${d(n)} + +`,e+=`--- + +`);const o=w(n);let t=!1,a=!1;for(const s of o){const c=p(s);let i;s.sender==="human"?(i=t?"H":"Human",t=!0):(i=a?"A":"Assistant",a=!0),e+=`${i}: ${c} + +`}return e.trim()}function f(n,r,e="application/json"){const o=new Blob([n],{type:e}),t=URL.createObjectURL(o),a=document.createElement("a");a.href=t,a.download=r,document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(t)}function l(n,r,e){const o=n.replace(/[^a-z0-9]/gi,"_").toLowerCase().substring(0,50),t=r==="json"?"json":r==="markdown"?"md":"txt",a=e?`_${e.substring(0,8)}`:"";return`${o}${a}.${t}`}async function $(n,r){const e=`https://claude.ai/api/organizations/${n}/chat_conversations/${r}?tree=True&rendering_mode=messages&render_all_tools=true`,o=await fetch(e,{credentials:"include",headers:{Accept:"application/json"}});if(!o.ok)throw new Error(`Failed to fetch conversation: ${o.status}`);return await o.json()}async function x(n){const r=`https://claude.ai/api/organizations/${n}/chat_conversations`,e=await fetch(r,{credentials:"include",headers:{Accept:"application/json"}});if(!e.ok)throw new Error(`Failed to fetch conversations: ${e.status}`);return await e.json()}async function _(n,r,e,o){console.log("Fetching conversation:",r);const t=await $(n,r);console.log("Conversation data fetched successfully:",t);const a=d(t);t.model=a;let s,c,i;switch(e){case"markdown":s=h(t,o),c=l(t.name||"untitled","markdown",t.uuid),i="text/markdown";break;case"text":s=v(t,o),c=l(t.name||"untitled","text",t.uuid),i="text/plain";break;default:s=JSON.stringify(t,null,2),c=l(t.name||"untitled","json",t.uuid),i="application/json"}console.log("Downloading file:",c),f(s,c,i)}async function k(n,r,e){const o=await x(n);if(console.log(`Fetched ${o.length} conversations`),r==="json"){const s=`claude-all-conversations-${new Date().toISOString().split("T")[0]??"export"}.json`;return console.log("Downloading all conversations as JSON:",s),f(JSON.stringify(o,null,2),s,"application/json"),{count:o.length}}let t=0;const a=[];for(const s of o)try{console.log(`Fetching full conversation ${t+1}/${o.length}: ${s.uuid}`);const c=await $(n,s.uuid);c.model=d(c);let i,m,g;r==="markdown"?(i=h(c,e),m=l(s.name||"untitled","markdown",s.uuid),g="text/markdown"):(i=v(c,e),m=l(s.name||"untitled","text",s.uuid),g="text/plain"),f(i,m,g),t++,await new Promise(C=>setTimeout(C,500))}catch(c){const i=c instanceof Error?c.message:"Unknown error";console.error(`Failed to export conversation ${s.uuid}:`,i),a.push(`${s.name||s.uuid}: ${i}`)}return a.length>0?(console.warn("Some conversations failed to export:",a),{count:t,warnings:`Exported ${t}/${o.length} conversations. Some failed: ${a.join("; ")}`}):{count:t}}chrome.runtime.onMessage.addListener((n,r)=>n.action==="exportConversation"?(console.log("Export conversation request received:",n),(async()=>{try{return await _(n.orgId,n.conversationId,n.format,n.includeMetadata),{success:!0}}catch(e){const o=e instanceof Error?e.message:"Unknown error",t=e instanceof Error?e.stack:void 0;return console.error("Export conversation error:",e),{success:!1,error:o,details:t}}})()):n.action==="exportAllConversations"?(console.log("Export all conversations request received:",n),(async()=>{try{const e=await k(n.orgId,n.format,n.includeMetadata);return{success:!0,count:e.count,warnings:e.warnings}}catch(e){const o=e instanceof Error?e.message:"Unknown error",t=e instanceof Error?e.stack:void 0;return console.error("Export all conversations error:",e),{success:!1,error:o,details:t}}})()):!1),console.log("Claude Conversation Exporter content script loaded")})(); diff --git a/docs/BROWSER_SPECIFIC_ARCHITECTURE.md b/docs/BROWSER_SPECIFIC_ARCHITECTURE.md new file mode 100644 index 0000000..da7d4bd --- /dev/null +++ b/docs/BROWSER_SPECIFIC_ARCHITECTURE.md @@ -0,0 +1,160 @@ +# Browser-Specific Architecture Changes + +## Summary + +The extension has been restructured to use **native browser APIs** for each platform instead of using a polyfill. Chrome uses `chrome.*` APIs and Firefox uses `browser.*` APIs. + +## New Structure + +``` +src/ +├── chrome/ +│ ├── manifest.json # Chrome MV3 manifest +│ └── scripts/ # Chrome-specific scripts using chrome.* API +│ ├── background.ts +│ ├── browse.html/ts +│ ├── content.ts +│ ├── options.html/ts +│ └── popup.html/ts +├── firefox/ +│ ├── manifest.json # Firefox MV2 manifest +│ └── scripts/ # Firefox-specific scripts using browser.* API +│ ├── background.ts +│ ├── browse.html/ts +│ ├── content.ts +│ ├── options.html/ts +│ └── popup.html/ts +├── types.ts # Shared type definitions +├── utils.ts # Shared utility functions +└── content-styles.css # Shared styles +``` + +## Key Changes + +### 1. Separate Script Directories + +- **Chrome scripts**: `src/chrome/scripts/` - Use `chrome.*` API +- **Firefox scripts**: `src/firefox/scripts/` - Use `browser.*` API +- **Shared code**: `src/types.ts`, `src/utils.ts` - No browser APIs + +### 2. Updated Manifests + +Both manifests now point to their browser-specific script directories: + +**Chrome** (`src/chrome/manifest.json`): +```json +{ + "action": { + "default_popup": "src/chrome/scripts/popup.html" + }, + "content_scripts": [{ + "js": ["src/chrome/scripts/content.ts"] + }], + "background": { + "service_worker": "src/chrome/scripts/background.ts" + } +} +``` + +**Firefox** (`src/firefox/manifest.json`): +```json +{ + "browser_action": { + "default_popup": "src/firefox/scripts/popup.html" + }, + "content_scripts": [{ + "js": ["src/firefox/scripts/content.ts"] + }], + "background": { + "scripts": ["src/firefox/scripts/background.ts"] + } +} +``` + +### 3. Build Process + +**New Build Configs**: +- `vite.content-chrome.config.ts` - Builds Chrome content script as IIFE +- `vite.content-firefox.config.ts` - Builds Firefox content script as IIFE +- `vite.config.ts` - Updated to use browser-specific input paths + +**Build Scripts**: +- `build-chrome.sh` - Builds Chrome extension with chrome.* APIs +- `build-firefox.sh` - Builds Firefox extension with browser.* APIs + +### 4. Script Conversion + +A `convert-to-chrome.sh` script was created to generate Chrome versions from Firefox scripts by replacing `browser.*` with `chrome.*`. + +## Building + +```bash +# Build Chrome extension +pnpm build:chrome + +# Build Firefox extension +pnpm build:firefox +``` + +## API Differences + +### Storage + +**Firefox**: +```typescript +const result = await browser.storage.sync.get(['organizationId']); +``` + +**Chrome**: +```typescript +const result = await chrome.storage.sync.get(['organizationId']); +``` + +### Tabs + +**Firefox**: +```typescript +const tabs = await browser.tabs.query({ active: true, currentWindow: true }); +await browser.tabs.sendMessage(tabId, message); +``` + +**Chrome**: +```typescript +const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); +await chrome.tabs.sendMessage(tabId, message); +``` + +### Runtime + +**Firefox**: +```typescript +const url = browser.runtime.getURL('src/firefox/scripts/browse.html'); +``` + +**Chrome**: +```typescript +const url = chrome.runtime.getURL('src/chrome/scripts/browse.html'); +``` + +## Benefits + +1. **No Polyfill Overhead** - Uses native APIs directly +2. **Better Type Safety** - Chrome gets `@types/chrome`, Firefox gets `firefox-webext-browser` +3. **Clearer Separation** - Browser-specific code is isolated +4. **Easier Debugging** - API calls match browser devtools exactly +5. **Future-Proof** - Each browser can evolve independently + +## Testing + +All 74 tests pass, including new build verification tests that check: +- Correct file paths for each browser +- Proper API usage (chrome.* vs browser.*) +- Content script sizes and functionality +- Icon files present +- Manifest validity + +## Migration Notes + +- Removed `src/browser-polyfill.ts` (no longer needed) +- TypeScript config excludes browser-specific folders to avoid duplicates +- Both builds produce working extensions with native APIs diff --git a/docs/CHROME_INSTALL.md b/docs/CHROME_INSTALL.md new file mode 100644 index 0000000..04cad55 --- /dev/null +++ b/docs/CHROME_INSTALL.md @@ -0,0 +1,57 @@ +# Loading the Extension in Chrome + +## Steps to Install + +1. **Build the Chrome extension**: + ```bash + pnpm build:chrome + ``` + +2. **Open Chrome Extensions page**: + - Navigate to `chrome://extensions/` + - OR: Menu → More Tools → Extensions + +3. **Enable Developer Mode**: + - Toggle the "Developer mode" switch in the top right corner + +4. **Load the extension**: + - Click "Load unpacked" + - Navigate to your project folder + - Select the `dist/chrome` folder + - Click "Select Folder" + +5. **Verify the extension**: + - You should see "Claude Conversation Exporter" appear in your extensions list + - The extension should show as "Enabled" + +## Testing the Extension + +1. Navigate to https://claude.ai +2. Open a conversation +3. Click the extension icon in your Chrome toolbar +4. Try the "Export Current Conversation" button + +## Troubleshooting + +### Extension not loading +- Make sure you selected the `dist/chrome` folder (not the project root) +- Check for any errors in the extensions page + +### Content script errors +- Open DevTools (F12) on a Claude.ai page +- Check the Console tab for errors +- You should see "Claude Conversation Exporter content script loaded" + +### Connection errors +- The content script only loads on `https://claude.ai/*` pages +- Make sure you're on a Claude.ai page when testing +- Try reloading the page after installing the extension + +## Development + +To rebuild after making changes: +```bash +pnpm build:chrome +``` + +Then click the refresh icon on the extension card in `chrome://extensions/` diff --git a/CLAUDE.md b/docs/CLAUDE.md similarity index 100% rename from CLAUDE.md rename to docs/CLAUDE.md diff --git a/CONNECTION_ERROR_FIX.md b/docs/CONNECTION_ERROR_FIX.md similarity index 100% rename from CONNECTION_ERROR_FIX.md rename to docs/CONNECTION_ERROR_FIX.md diff --git a/CONTENT_SCRIPT_FIX.md b/docs/CONTENT_SCRIPT_FIX.md similarity index 100% rename from CONTENT_SCRIPT_FIX.md rename to docs/CONTENT_SCRIPT_FIX.md diff --git a/EXPORT_OPTIONS.md b/docs/EXPORT_OPTIONS.md similarity index 100% rename from EXPORT_OPTIONS.md rename to docs/EXPORT_OPTIONS.md diff --git a/TEST_SETUP_SUMMARY.md b/docs/TEST_SETUP_SUMMARY.md similarity index 100% rename from TEST_SETUP_SUMMARY.md rename to docs/TEST_SETUP_SUMMARY.md diff --git a/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md similarity index 100% rename from TROUBLESHOOTING.md rename to docs/TROUBLESHOOTING.md diff --git a/eslint.config.js b/eslint.config.js index bf1e067..631f7ff 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,15 @@ import prettier from 'eslint-plugin-prettier/recommended'; export default tseslint.config( // Ignore build outputs and dependencies { - ignores: ['dist/**', 'node_modules/**', '*.js.map', 'web-ext-artifacts/**'], + ignores: [ + 'dist/**', + 'node_modules/**', + '*.js.map', + 'web-ext-artifacts/**', + // Browser-specific scripts are compiled separately by Vite + 'src/chrome/scripts/**', + 'src/firefox/scripts/**', + ], }, // Base JavaScript recommended rules diff --git a/package.json b/package.json index 2fd4700..472a494 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "tsc && vite build", "build:firefox": "bash build-firefox.sh", - "build:chrome": "tsc && vite build --mode chrome", + "build:chrome": "bash build-chrome.sh", "dev": "vite build --watch", "dev:firefox": "vite build --mode firefox --watch", "dev:chrome": "vite build --mode chrome --watch", @@ -39,6 +39,7 @@ "devDependencies": { "@eslint/js": "^9.38.0", "@samrum/vite-plugin-web-extension": "^5.1.1", + "@types/chrome": "^0.1.24", "@types/firefox-webext-browser": "^143.0.0", "@types/jszip": "^3.4.0", "@typescript-eslint/eslint-plugin": "^8.46.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3244ad4..c4d46b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@samrum/vite-plugin-web-extension': specifier: ^5.1.1 version: 5.1.1(vite@7.1.10(@types/node@24.8.1)) + '@types/chrome': + specifier: ^0.1.24 + version: 0.1.24 '@types/firefox-webext-browser': specifier: ^143.0.0 version: 143.0.0 @@ -577,6 +580,9 @@ packages: '@types/chrome@0.0.233': resolution: {integrity: sha512-T3HUW8LDhHfYPbUkOZilhnqX04sZqEFIQO0OIG8byZ7PBIk2jYys1AkNg56UbtKbsh3dABmOniK+0Q3oyo7Sxg==} + '@types/chrome@0.1.24': + resolution: {integrity: sha512-9iO9HL2bMeGS4C8m6gNFWUyuPE5HEUFk+rGh+7oriUjg+ata4Fc9PoVlu8xvGm7yoo3AmS3J6fAjoFj61NL2rw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2773,6 +2779,11 @@ snapshots: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 + '@types/chrome@0.1.24': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} diff --git a/src/chrome/manifest.json b/src/chrome/manifest.json index 0078f30..46427bd 100644 --- a/src/chrome/manifest.json +++ b/src/chrome/manifest.json @@ -6,7 +6,7 @@ "permissions": ["activeTab", "storage", "scripting"], "host_permissions": ["https://claude.ai/*"], "action": { - "default_popup": "src/popup.html", + "default_popup": "src/chrome/scripts/popup.html", "default_icon": { "16": "src/icon16.png", "48": "src/icon48.png", @@ -16,22 +16,22 @@ "content_scripts": [ { "matches": ["https://claude.ai/*"], - "js": ["src/content.ts"], + "js": ["src/chrome/scripts/content.ts"], "css": ["src/content-styles.css"] } ], "background": { - "service_worker": "src/background.ts" + "service_worker": "src/chrome/scripts/background.ts" }, "icons": { "16": "src/icon16.png", "48": "src/icon48.png", "128": "src/icon128.png" }, - "options_page": "src/options.html", + "options_page": "src/chrome/scripts/options.html", "web_accessible_resources": [ { - "resources": ["src/browse.html"], + "resources": ["src/chrome/scripts/browse.html"], "matches": [""] } ] diff --git a/src/chrome/scripts/background.ts b/src/chrome/scripts/background.ts new file mode 100644 index 0000000..f2b4568 --- /dev/null +++ b/src/chrome/scripts/background.ts @@ -0,0 +1,79 @@ +// @ts-nocheck - File is compiled by Vite, not included in tsconfig.json +/** + * Chrome Background Service Worker + * Handles extension lifecycle, content script injection, and message routing + */ + +import type { ExtensionMessage, ExtensionResponse } from '../../types'; + +/** + * Handle extension installation + */ +chrome.runtime.onInstalled.addListener(() => async () => { + console.log('Claude Conversation Exporter installed'); + + // Inject content script into already-open Claude.ai tabs + try { + const tabs = await chrome.tabs.query({ url: 'https://claude.ai/*' }); + + for (const tab of tabs) { + if (tab.id) { + try { + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ['src/chrome/scripts/content.js'], + }); + } catch (err) { + console.log('Could not inject into tab', tab.id, err); + } + } + } + } catch (err) { + console.error('Error querying or injecting tabs:', err); + } +}); + +/** + * Handle messages from popup or other extension components + */ +chrome.runtime.onMessage.addListener( + // @ts-expect-error - Chrome types expect unknown parameters, but we type them for safety + ( + request: ExtensionMessage, + _sender: chrome.runtime.MessageSender + ): Promise | boolean => { + if (request.action === 'ensureContentScript') { + return (async () => { + try { + const tabs = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + + const activeTab = tabs[0]; + if (activeTab?.id) { + await chrome.scripting.executeScript({ + target: { tabId: activeTab.id }, + files: ['src/chrome/scripts/content.js'], + }); + + return { success: true }; + } + + return { + success: false, + error: 'No active tab found', + }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + }; + } + })(); + } + + // Return false to indicate no response will be sent + return false; + } +); diff --git a/src/chrome/scripts/browse.html b/src/chrome/scripts/browse.html new file mode 100644 index 0000000..af354f7 --- /dev/null +++ b/src/chrome/scripts/browse.html @@ -0,0 +1,542 @@ + + + + + Browse Claude Conversations + + + +
+

Browse Claude Conversations

+
+ + +
+ + +
+ +
+ + +
+ +
Loading...
+ +
+ + +
+ + +
+
+ +
+
+
+
+
+
Loading conversations...
+
+
+
+
+ + + + +
+ + +
+
+

Exporting Conversations

+
+
Preparing export...
+
+
+
+
+
+ +
+
+ + + + diff --git a/src/chrome/scripts/browse.ts b/src/chrome/scripts/browse.ts new file mode 100644 index 0000000..cd5819e --- /dev/null +++ b/src/chrome/scripts/browse.ts @@ -0,0 +1,688 @@ +/** + * Browse page script for Claude Conversation Exporter + * Displays all conversations in a searchable/filterable table + */ + +import JSZip from 'jszip'; +import type { Conversation, ConversationListItem, ExportFormat } from '../../types'; +import { + convertToMarkdown, + convertToText, + downloadFile, + generateFilename, + inferModel, +} from '../../utils'; + +/** + * Model display name mappings + */ +const MODEL_DISPLAY_NAMES: Record = { + 'claude-3-sonnet-20240229': 'Claude 3 Sonnet', + 'claude-3-opus-20240229': 'Claude 3 Opus', + 'claude-3-haiku-20240307': 'Claude 3 Haiku', + 'claude-3-5-sonnet-20240620': 'Claude 3.5 Sonnet', + 'claude-3-5-haiku-20241022': 'Claude 3.5 Haiku', + 'claude-3-5-sonnet-20241022': 'Claude 3.6 Sonnet', + 'claude-3-7-sonnet-20250219': 'Claude 3.7 Sonnet', + 'claude-sonnet-4-20250514': 'Claude Sonnet 4', + 'claude-opus-4-20250514': 'Claude Opus 4', + 'claude-opus-4-1-20250805': 'Claude Opus 4.1', + 'claude-sonnet-4-5-20250929': 'Claude Sonnet 4.5', +}; + +/** + * State management + */ +interface BrowseState { + allConversations: ConversationListItem[]; + filteredConversations: ConversationListItem[]; + orgId: string | null; + currentSort: string; + cancelExport: boolean; +} + +const state: BrowseState = { + allConversations: [], + filteredConversations: [], + orgId: null, + currentSort: 'updated_desc', + cancelExport: false, +}; + +/** + * Get HTML element by ID with type checking + */ +function getElement(id: string): T | null { + return document.getElementById(id) as T | null; +} + +/** + * Load organization ID from storage + */ +async function loadOrgId(): Promise { + console.log('[Browse] Loading org ID...'); + try { + const result = await chrome.storage.sync.get(['organizationId']); + state.orgId = (result['organizationId'] as string) || null; + console.log('[Browse] Org ID loaded:', state.orgId); + + if (!state.orgId) { + console.warn('[Browse] No org ID configured'); + showError( + 'Organization ID not configured. Please configure it in the extension options.' + ); + } + } catch (error) { + console.error('[Browse] Error loading org ID:', error); + } +} + +/** + * Load all conversations from Claude API + */ +async function loadConversations(): Promise { + console.log('[Browse] loadConversations called, orgId:', state.orgId); + if (!state.orgId) { + console.warn('[Browse] No org ID, skipping conversation load'); + return; + } + + try { + console.log('[Browse] Fetching conversations from API...'); + const response = await fetch( + `https://claude.ai/api/organizations/${state.orgId}/chat_conversations`, + { + credentials: 'include', + headers: { + Accept: 'application/json', + }, + } + ); + + console.log('[Browse] API response status:', response.status); + if (!response.ok) { + throw new Error(`Failed to load conversations: ${response.status}`); + } + + const conversations = (await response.json()) as ConversationListItem[]; + console.log(`[Browse] Loaded ${conversations.length} conversations`); + + // Infer models for conversations with null model + state.allConversations = conversations.map((conv) => ({ + ...conv, + model: inferModel(conv), + })); + + // Extract unique models for filter + const models = [ + ...new Set( + state.allConversations.map((c) => c.model).filter((m): m is string => Boolean(m)) + ), + ].sort(); + populateModelFilter(models); + + // Apply initial sort and display + applyFiltersAndSort(); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('Error loading conversations:', error); + showError(`Failed to load conversations: ${errorMsg}`); + } +} + +/** + * Populate model filter dropdown + */ +function populateModelFilter(models: string[]): void { + const modelFilter = getElement('modelFilter'); + if (!modelFilter) return; + + modelFilter.innerHTML = ''; + + for (const model of models) { + const option = document.createElement('option'); + option.value = model; + option.textContent = formatModelName(model); + modelFilter.appendChild(option); + } +} + +/** + * Format model name for display + */ +function formatModelName(model: string): string { + return MODEL_DISPLAY_NAMES[model] ?? model; +} + +/** + * Get model badge CSS class + */ +function getModelBadgeClass(model: string): string { + if (model.includes('sonnet')) return 'sonnet'; + if (model.includes('opus')) return 'opus'; + if (model.includes('haiku')) return 'haiku'; + return ''; +} + +/** + * Apply filters and sorting + */ +function applyFiltersAndSort(): void { + const searchInput = getElement('searchInput'); + const modelFilter = getElement('modelFilter'); + + const searchTerm = searchInput?.value.toLowerCase() ?? ''; + const modelFilterValue = modelFilter?.value ?? ''; + + // Filter conversations + state.filteredConversations = state.allConversations.filter((conv) => { + const matchesSearch = + !searchTerm || + conv.name.toLowerCase().includes(searchTerm) || + ('summary' in conv && + typeof conv['summary'] === 'string' && + conv['summary'].toLowerCase().includes(searchTerm)); + + const matchesModel = !modelFilterValue || conv.model === modelFilterValue; + + return matchesSearch && matchesModel; + }); + + // Sort conversations + sortConversations(); + + // Update display + displayConversations(); + updateStats(); +} + +/** + * Sort conversations based on current sort setting + */ +function sortConversations(): void { + const [field, direction] = state.currentSort.split('_'); + + state.filteredConversations.sort((a, b) => { + let aVal: string | Date; + let bVal: string | Date; + + switch (field) { + case 'name': + aVal = a.name.toLowerCase(); + bVal = b.name.toLowerCase(); + break; + case 'created': + aVal = new Date(a.created_at); + bVal = new Date(b.created_at); + break; + case 'updated': + aVal = new Date(a.updated_at); + bVal = new Date(b.updated_at); + break; + default: + return 0; + } + + if (direction === 'asc') { + return aVal > bVal ? 1 : -1; + } else { + return aVal < bVal ? 1 : -1; + } + }); +} + +/** + * Display conversations in table + */ +function displayConversations(): void { + console.log( + '[Browse] displayConversations called, filtered count:', + state.filteredConversations.length + ); + const tableContent = getElement('tableContent'); + if (!tableContent) { + console.error('[Browse] tableContent element not found!'); + return; + } + + if (state.filteredConversations.length === 0) { + console.log('[Browse] No conversations to display'); + tableContent.innerHTML = '
No conversations found
'; + return; + } + + console.log( + '[Browse] Rendering table with', + state.filteredConversations.length, + 'conversations' + ); + + let html = ` + + + + + + + + + + + + `; + + for (const conv of state.filteredConversations) { + const updatedDate = new Date(conv.updated_at).toLocaleDateString(); + const createdDate = new Date(conv.created_at).toLocaleDateString(); + const modelBadgeClass = getModelBadgeClass(conv.model ?? ''); + + html += ` + + + + + + + + `; + } + + html += ` + +
NameLast UpdatedCreatedModelActions
+ + ${updatedDate}${createdDate} + + ${formatModelName(conv.model ?? '')} + + +
+ + +
+
+ `; + + tableContent.innerHTML = html; + + // Add export button listeners + const exportButtons = document.querySelectorAll('.btn-export'); + exportButtons.forEach((btn) => { + btn.addEventListener('click', () => { + const id = btn.dataset['id']; + const name = btn.dataset['name']; + if (id && name) { + void exportConversation(id, name); + } + }); + }); + + // Add view button listeners + const viewButtons = document.querySelectorAll('.btn-view'); + viewButtons.forEach((btn) => { + btn.addEventListener('click', () => { + const conversationId = btn.dataset['id']; + if (conversationId) { + window.open(`https://claude.ai/chat/${conversationId}`, '_blank'); + } + }); + }); + + // Enable export all button + const exportAllBtn = getElement('exportAllBtn'); + if (exportAllBtn) { + exportAllBtn.disabled = false; + } +} + +/** + * Update statistics display + */ +function updateStats(): void { + const stats = getElement('stats'); + if (!stats) return; + + stats.textContent = `Showing ${state.filteredConversations.length} of ${state.allConversations.length} conversations`; +} + +/** + * Export single conversation + */ +async function exportConversation( + conversationId: string, + conversationName: string +): Promise { + const formatSelect = getElement('exportFormat'); + const metadataCheck = getElement('includeMetadata'); + + const format = (formatSelect?.value as ExportFormat) || 'json'; + const includeMetadata = metadataCheck?.checked ?? true; + + try { + showToast(`Exporting ${conversationName}...`); + + const response = await fetch( + `https://claude.ai/api/organizations/${state.orgId}/chat_conversations/${conversationId}?tree=True&rendering_mode=messages&render_all_tools=true`, + { + credentials: 'include', + headers: { + Accept: 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch conversation: ${response.status}`); + } + + const data = (await response.json()) as Conversation; + + // Infer model if null + data.model = inferModel(data); + + let content: string; + let filename: string; + let type: string; + + switch (format) { + case 'markdown': + content = convertToMarkdown(data, includeMetadata); + filename = generateFilename(conversationName, 'markdown', data.uuid); + type = 'text/markdown'; + break; + case 'text': + content = convertToText(data, includeMetadata); + filename = generateFilename(conversationName, 'text', data.uuid); + type = 'text/plain'; + break; + default: + content = JSON.stringify(data, null, 2); + filename = generateFilename(conversationName, 'json', data.uuid); + type = 'application/json'; + } + + downloadFile(content, filename, type); + showToast(`Exported: ${conversationName}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('Export error:', error); + showToast(`Failed to export: ${errorMsg}`, true); + } +} + +/** + * Export all filtered conversations as ZIP + */ +async function exportAllFiltered(): Promise { + const formatSelect = getElement('exportFormat'); + const metadataCheck = getElement('includeMetadata'); + const button = getElement('exportAllBtn'); + + const format = (formatSelect?.value as ExportFormat) || 'json'; + const includeMetadata = metadataCheck?.checked ?? true; + + if (!button) return; + + button.disabled = true; + button.textContent = 'Preparing...'; + + // Show progress modal + const progressModal = getElement('progressModal'); + const progressBar = getElement('progressBar'); + const progressText = getElement('progressText'); + const progressStats = getElement('progressStats'); + + if (!progressModal || !progressBar || !progressText || !progressStats) { + showToast('Error: Progress UI elements not found', true); + return; + } + + progressModal.style.display = 'block'; + state.cancelExport = false; + + const cancelButton = getElement('cancelExport'); + if (cancelButton) { + cancelButton.onclick = () => { + state.cancelExport = true; + progressText.textContent = 'Cancelling...'; + }; + } + + try { + const zip = new JSZip(); + const total = state.filteredConversations.length; + let completed = 0; + let failed = 0; + const failedConversations: string[] = []; + + progressText.textContent = `Exporting ${total} conversations...`; + + // Process conversations in batches + const batchSize = 3; + for (let i = 0; i < total; i += batchSize) { + if (state.cancelExport) break; + + const batch = state.filteredConversations.slice(i, Math.min(i + batchSize, total)); + const promises = batch.map(async (conv) => { + try { + const response = await fetch( + `https://claude.ai/api/organizations/${state.orgId}/chat_conversations/${conv.uuid}?tree=True&rendering_mode=messages&render_all_tools=true`, + { + credentials: 'include', + headers: { + Accept: 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = (await response.json()) as Conversation; + data.model = inferModel(data); + + let content: string; + let filename: string; + const safeName = conv.name.replace(/[<>:"/\\|?*]/g, '_'); + + switch (format) { + case 'markdown': + content = convertToMarkdown(data, includeMetadata); + filename = `${safeName}.md`; + break; + case 'text': + content = convertToText(data, includeMetadata); + filename = `${safeName}.txt`; + break; + default: + content = JSON.stringify(data, null, 2); + filename = `${safeName}.json`; + } + + zip.file(filename, content); + completed++; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error(`Failed to export ${conv.name}:`, errorMsg); + failed++; + failedConversations.push(conv.name); + } + }); + + await Promise.all(promises); + + // Update progress + const progress = Math.round(((completed + failed) / total) * 100); + progressBar.style.width = `${progress}%`; + progressStats.textContent = `${completed} succeeded, ${failed} failed out of ${total}`; + + // Small delay between batches + if (i + batchSize < total && !state.cancelExport) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + + if (state.cancelExport) { + progressModal.style.display = 'none'; + showToast('Export cancelled', true); + return; + } + + // Add summary file + const summary = { + export_date: new Date().toISOString(), + total_conversations: total, + successful_exports: completed, + failed_exports: failed, + failed_conversations: failedConversations, + format: format, + include_metadata: includeMetadata, + }; + zip.file('export_summary.json', JSON.stringify(summary, null, 2)); + + // Generate and download ZIP + progressText.textContent = 'Creating ZIP file...'; + const blob = await zip.generateAsync( + { + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { + level: 6, + }, + }, + (metadata) => { + const zipProgress = Math.round(metadata.percent); + progressBar.style.width = `${zipProgress}%`; + } + ); + + // Download the ZIP file + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `claude-conversations-${ + new Date().toISOString().split('T')[0] ?? 'export' + }.zip`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + progressModal.style.display = 'none'; + + if (failed > 0) { + showToast( + `Exported ${completed} of ${total} conversations (${failed} failed). Check export_summary.json in the ZIP for details.` + ); + } else { + showToast(`Successfully exported all ${completed} conversations!`); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('Export error:', error); + progressModal.style.display = 'none'; + showToast(`Export failed: ${errorMsg}`, true); + } finally { + button.disabled = false; + button.textContent = 'Export All'; + } +} + +/** + * Show error message + */ +function showError(message: string): void { + const tableContent = getElement('tableContent'); + if (!tableContent) return; + + tableContent.innerHTML = `
${message}
`; +} + +/** + * Show toast notification + */ +function showToast(message: string, isError = false): void { + const toast = getElement('toast'); + if (!toast) return; + + toast.textContent = message; + toast.style.background = isError ? '#d32f2f' : '#333'; + toast.classList.add('show'); + + setTimeout(() => { + toast.classList.remove('show'); + }, 3000); +} + +/** + * Setup event listeners + */ +function setupEventListeners(): void { + // Search input + const searchInput = getElement('searchInput'); + const searchBox = getElement('searchBox'); + searchInput?.addEventListener('input', (e) => { + const target = e.target as HTMLInputElement; + if (searchBox) { + if (target.value) { + searchBox.classList.add('has-text'); + } else { + searchBox.classList.remove('has-text'); + } + } + applyFiltersAndSort(); + }); + + // Clear search + const clearSearch = getElement('clearSearch'); + clearSearch?.addEventListener('click', () => { + if (searchInput) searchInput.value = ''; + searchBox?.classList.remove('has-text'); + applyFiltersAndSort(); + }); + + // Model filter + const modelFilter = getElement('modelFilter'); + modelFilter?.addEventListener('change', applyFiltersAndSort); + + // Sort dropdown + const sortBy = getElement('sortBy'); + sortBy?.addEventListener('change', (e) => { + const target = e.target as HTMLSelectElement; + state.currentSort = target.value; + applyFiltersAndSort(); + }); + + // Export all button + const exportAllBtn = getElement('exportAllBtn'); + exportAllBtn?.addEventListener('click', () => { + void exportAllFiltered(); + }); +} + +/** + * Initialize on page load + */ +console.log('[Browse] Script loaded, waiting for DOMContentLoaded...'); +document.addEventListener('DOMContentLoaded', () => { + console.log('[Browse] DOMContentLoaded fired, initializing...'); + void (async () => { + try { + await loadOrgId(); + await loadConversations(); + setupEventListeners(); + console.log('[Browse] Initialization complete'); + } catch (error) { + console.error('[Browse] Initialization error:', error); + } + })(); +}); diff --git a/src/chrome/scripts/content.ts b/src/chrome/scripts/content.ts new file mode 100644 index 0000000..0913653 --- /dev/null +++ b/src/chrome/scripts/content.ts @@ -0,0 +1,247 @@ +// @ts-nocheck - File is compiled by Vite, not included in tsconfig.json +/** + * Content script for Claude Conversation Exporter + * Runs on claude.ai pages to handle conversation exports + */ + +import type { + Conversation, + ConversationListItem, + ExtensionMessage, + ExtensionResponse, + ExportFormat, +} from '../../types'; +import { + convertToMarkdown, + convertToText, + downloadFile, + generateFilename, + inferModel, +} from '../../utils'; + +/** + * Fetch a single conversation from Claude API + */ +async function fetchConversation( + orgId: string, + conversationId: string +): Promise { + const url = `https://claude.ai/api/organizations/${orgId}/chat_conversations/${conversationId}?tree=True&rendering_mode=messages&render_all_tools=true`; + + const response = await fetch(url, { + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch conversation: ${response.status}`); + } + + return (await response.json()) as Conversation; +} + +/** + * Fetch all conversations from Claude API + */ +async function fetchAllConversations(orgId: string): Promise { + const url = `https://claude.ai/api/organizations/${orgId}/chat_conversations`; + + const response = await fetch(url, { + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch conversations: ${response.status}`); + } + + return (await response.json()) as ConversationListItem[]; +} + +/** + * Export a single conversation + */ +async function exportConversation( + orgId: string, + conversationId: string, + format: ExportFormat, + includeMetadata: boolean +): Promise { + console.log('Fetching conversation:', conversationId); + const data = await fetchConversation(orgId, conversationId); + + console.log('Conversation data fetched successfully:', data); + + // Infer model if null + const modelName = inferModel(data); + data.model = modelName; + + let content: string; + let filename: string; + let type: string; + + switch (format) { + case 'markdown': + content = convertToMarkdown(data, includeMetadata); + filename = generateFilename(data.name || 'untitled', 'markdown', data.uuid); + type = 'text/markdown'; + break; + case 'text': + content = convertToText(data, includeMetadata); + filename = generateFilename(data.name || 'untitled', 'text', data.uuid); + type = 'text/plain'; + break; + default: + content = JSON.stringify(data, null, 2); + filename = generateFilename(data.name || 'untitled', 'json', data.uuid); + type = 'application/json'; + } + + console.log('Downloading file:', filename); + downloadFile(content, filename, type); +} + +/** + * Export all conversations + */ +async function exportAllConversations( + orgId: string, + format: ExportFormat, + includeMetadata: boolean +): Promise<{ count: number; warnings?: string }> { + const conversations = await fetchAllConversations(orgId); + console.log(`Fetched ${conversations.length} conversations`); + + if (format === 'json') { + // For JSON, export as a single file with all conversations + const filename = `claude-all-conversations-${ + new Date().toISOString().split('T')[0] ?? 'export' + }.json`; + console.log('Downloading all conversations as JSON:', filename); + downloadFile(JSON.stringify(conversations, null, 2), filename, 'application/json'); + return { count: conversations.length }; + } + + // For other formats, create individual files + let count = 0; + const errors: string[] = []; + + for (const conv of conversations) { + try { + console.log( + `Fetching full conversation ${count + 1}/${conversations.length}: ${conv.uuid}` + ); + const fullConv = await fetchConversation(orgId, conv.uuid); + + // Infer model if null + fullConv.model = inferModel(fullConv); + + let content: string; + let filename: string; + let type: string; + + if (format === 'markdown') { + content = convertToMarkdown(fullConv, includeMetadata); + filename = generateFilename(conv.name || 'untitled', 'markdown', conv.uuid); + type = 'text/markdown'; + } else { + content = convertToText(fullConv, includeMetadata); + filename = generateFilename(conv.name || 'untitled', 'text', conv.uuid); + type = 'text/plain'; + } + + downloadFile(content, filename, type); + count++; + + // Add a small delay to avoid overwhelming the API + await new Promise((resolve) => setTimeout(resolve, 500)); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error(`Failed to export conversation ${conv.uuid}:`, errorMsg); + errors.push(`${conv.name || conv.uuid}: ${errorMsg}`); + } + } + + if (errors.length > 0) { + console.warn('Some conversations failed to export:', errors); + return { + count, + warnings: `Exported ${count}/${conversations.length} conversations. Some failed: ${errors.join('; ')}`, + }; + } + + return { count }; +} + +/** + * Handle messages from popup or background script + */ +chrome.runtime.onMessage.addListener( + // @ts-expect-error - Chrome types expect unknown parameters, but we type them for safety + ( + request: ExtensionMessage, + _sender: chrome.runtime.MessageSender + ): Promise | boolean => { + if (request.action === 'exportConversation') { + console.log('Export conversation request received:', request); + + return (async () => { + try { + await exportConversation( + request.orgId, + request.conversationId, + request.format, + request.includeMetadata + ); + return { success: true }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + const errorStack = error instanceof Error ? error.stack : undefined; + console.error('Export conversation error:', error); + return { + success: false, + error: errorMsg, + details: errorStack, + }; + } + })(); + } + + if (request.action === 'exportAllConversations') { + console.log('Export all conversations request received:', request); + + return (async () => { + try { + const result = await exportAllConversations( + request.orgId, + request.format, + request.includeMetadata + ); + return { + success: true, + count: result.count, + warnings: result.warnings, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + const errorStack = error instanceof Error ? error.stack : undefined; + console.error('Export all conversations error:', error); + return { + success: false, + error: errorMsg, + details: errorStack, + }; + } + })(); + } + + // Return false to indicate no response will be sent + return false; + } +); + +console.log('Claude Conversation Exporter content script loaded'); diff --git a/src/chrome/scripts/options.html b/src/chrome/scripts/options.html new file mode 100644 index 0000000..6dfed7a --- /dev/null +++ b/src/chrome/scripts/options.html @@ -0,0 +1,139 @@ + + + + + Claude Exporter Settings + + + +

Claude Conversation Exporter Settings

+ +
+

How to find your Organization ID:

+
    +
  1. + Go to + claude.ai/settings/account +
  2. +
  3. + Copy your Organization ID (e.g., + xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) +
  4. +
  5. Paste it below and click Save
  6. +
+
+ +
+ + +
+ This is a UUID that looks like: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +
+ +
+
+ +
+

Test Your Settings

+

After saving your Organization ID, you can test if it works:

+ +
+
+ + + + diff --git a/src/chrome/scripts/options.ts b/src/chrome/scripts/options.ts new file mode 100644 index 0000000..dafba6a --- /dev/null +++ b/src/chrome/scripts/options.ts @@ -0,0 +1,161 @@ +// @ts-nocheck - File is compiled by Vite, not included in tsconfig.json +/** + * Options page script for Claude Conversation Exporter + * Handles extension configuration and settings + */ + +type StatusType = 'success' | 'error' | 'info'; + +/** + * Get HTML element by ID with type checking + */ +function getElement(id: string): T | null { + return document.getElementById(id) as T | null; +} + +/** + * Show status message + */ +function showStatus(elementId: string, message: string, type: StatusType): void { + const statusEl = getElement(elementId); + if (!statusEl) return; + + statusEl.textContent = message; + statusEl.className = `status ${type}`; +} + +/** + * Hide status message + */ +function hideStatus(elementId: string): void { + const statusEl = getElement(elementId); + if (!statusEl) return; + + statusEl.className = 'status'; + statusEl.textContent = ''; +} + +/** + * Load saved settings on page load + */ +async function loadSettings(): Promise { + try { + const result = await chrome.storage.sync.get(['organizationId']); + const orgId = result['organizationId'] as string | undefined; + + if (orgId) { + const orgIdInput = getElement('orgId'); + if (orgIdInput) { + orgIdInput.value = orgId; + } + showStatus('status', 'Organization ID loaded from saved settings', 'success'); + setTimeout(() => hideStatus('status'), 2000); + } + } catch (error) { + console.error('Error loading settings:', error); + } +} + +/** + * Save settings + */ +async function saveSettings(): Promise { + const orgIdInput = getElement('orgId'); + if (!orgIdInput) return; + + const orgId = orgIdInput.value.trim(); + + if (!orgId) { + showStatus('status', 'Please enter an Organization ID', 'error'); + return; + } + + // Validate UUID format + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(orgId)) { + showStatus( + 'status', + 'Invalid Organization ID format. It should be a UUID like: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'error' + ); + return; + } + + try { + await chrome.storage.sync.set({ organizationId: orgId }); + showStatus('status', 'Settings saved successfully!', 'success'); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + showStatus('status', `Error saving settings: ${errorMsg}`, 'error'); + } +} + +/** + * Test connection to Claude API + */ +async function testConnection(): Promise { + const orgIdInput = getElement('orgId'); + if (!orgIdInput) return; + + const orgId = orgIdInput.value.trim(); + + if (!orgId) { + showStatus('testStatus', 'Please save an Organization ID first', 'error'); + return; + } + + showStatus('testStatus', 'Testing connection...', 'info'); + + try { + const response = await fetch( + `https://claude.ai/api/organizations/${orgId}/chat_conversations`, + { + credentials: 'include', + headers: { + Accept: 'application/json', + }, + } + ); + + if (response.ok) { + const data = (await response.json()) as unknown[]; + showStatus('testStatus', `Success! Found ${data.length} conversations.`, 'success'); + } else if (response.status === 401) { + showStatus( + 'testStatus', + 'Not authenticated. Please make sure you are logged into Claude.ai', + 'error' + ); + } else if (response.status === 403) { + showStatus( + 'testStatus', + 'Access denied. The Organization ID might be incorrect.', + 'error' + ); + } else { + showStatus( + 'testStatus', + `Connection failed with status: ${response.status}`, + 'error' + ); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + showStatus('testStatus', `Connection error: ${errorMsg}`, 'error'); + } +} + +/** + * Initialize options page + */ +document.addEventListener('DOMContentLoaded', () => { + // Load saved settings + void loadSettings(); + + // Set up event listeners + const saveBtn = getElement('saveBtn'); + const testBtn = getElement('testBtn'); + + saveBtn?.addEventListener('click', () => void saveSettings()); + testBtn?.addEventListener('click', () => void testConnection()); +}); diff --git a/src/chrome/scripts/popup.html b/src/chrome/scripts/popup.html new file mode 100644 index 0000000..655fc96 --- /dev/null +++ b/src/chrome/scripts/popup.html @@ -0,0 +1,121 @@ + + + + + + + +
+ Claude Conversation Exporter +
+ +
+ + +
+ + + +
+ + + + + +
+
+ + + + diff --git a/src/chrome/scripts/popup.ts b/src/chrome/scripts/popup.ts new file mode 100644 index 0000000..57ceb8a --- /dev/null +++ b/src/chrome/scripts/popup.ts @@ -0,0 +1,266 @@ +// @ts-nocheck - File is compiled by Vite, not included in tsconfig.json +/** + * Popup script for Claude Conversation Exporter + * Handles the extension popup UI and user interactions + */ + +/// + +import type { ExtensionResponse } from '../../types'; + +/** + * Get organization ID from storage + */ +async function getOrgId(): Promise { + const result = await chrome.storage.sync.get(['organizationId']); + return result.organizationId as string | undefined; +} + +/** + * Get current conversation ID from active tab URL + */ +async function getCurrentConversationId(): Promise { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tab = tabs[0]; + + if (!tab?.url) { + return null; + } + + const url = new URL(tab.url); + const match = /\/chat\/([a-f0-9-]+)/.exec(url.pathname); + return match?.[1] ?? null; +} + +/** + * Show status message to user + */ +function showStatus(message: string, type: 'info' | 'success' | 'error' = 'info'): void { + const statusEl = document.getElementById('status'); + if (!statusEl) return; + + statusEl.className = `status ${type}`; + statusEl.textContent = message; + + if (type === 'success') { + setTimeout(() => { + statusEl.textContent = ''; + statusEl.className = ''; + }, 3000); + } +} + +/** + * Get HTML element by ID with type checking + */ +function getElement(id: string): T | null { + return document.getElementById(id) as T | null; +} + +/** + * Check if org ID is configured on popup load + */ +async function initializePopup(): Promise { + const orgId = await getOrgId(); + + if (!orgId) { + const setupNotice = getElement('setupNotice'); + if (setupNotice) { + setupNotice.style.display = 'block'; + } + + const exportCurrentBtn = getElement('exportCurrent'); + const exportAllBtn = getElement('exportAll'); + + if (exportCurrentBtn) exportCurrentBtn.disabled = true; + if (exportAllBtn) exportAllBtn.disabled = true; + } +} + +/** + * Handle options link click + */ +function setupOptionsLink(): void { + const openOptionsBtn = getElement('openOptions'); + openOptionsBtn?.addEventListener('click', (e) => { + e.preventDefault(); + void chrome.runtime.openOptionsPage(); + }); +} + +/** + * Handle export current conversation + */ +async function handleExportCurrent(): Promise { + const button = getElement('exportCurrent'); + if (!button) return; + + button.disabled = true; + showStatus('Fetching conversation...', 'info'); + + try { + const orgId = await getOrgId(); + const conversationId = await getCurrentConversationId(); + + if (!orgId) { + throw new Error( + 'Organization ID not configured. Click the setup link above to configure it.' + ); + } + + if (!conversationId) { + throw new Error( + 'Could not detect a conversation ID. Make sure you are on a Claude.ai conversation page.' + ); + } + + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tab = tabs[0]; + + // Check if we're on Claude.ai + if (!tab?.url?.includes('claude.ai')) { + throw new Error('Please navigate to a Claude.ai conversation page first.'); + } + + if (!tab.id) { + throw new Error('Could not identify active tab.'); + } + + const formatSelect = getElement('format'); + const metadataCheck = getElement('includeMetadata'); + + try { + const response = (await chrome.tabs.sendMessage(tab.id, { + action: 'exportConversation', + conversationId, + orgId, + format: formatSelect?.value ?? 'json', + includeMetadata: metadataCheck?.checked ?? true, + })) as ExtensionResponse; + + if (response?.success) { + showStatus('Conversation exported successfully!', 'success'); + } else if (response?.error) { + // Only show error if there's an actual error message + console.error('Export failed:', response.error, response?.details); + showStatus(response.error, 'error'); + } else { + // No explicit success or error - assume download was triggered + showStatus('Download started...', 'success'); + } + } catch (sendError) { + // Handle "Could not establish connection" error + const errorMsg = sendError instanceof Error ? sendError.message : String(sendError); + if (errorMsg.includes('connection') || errorMsg.includes('Receiving end')) { + throw new Error( + 'Cannot connect to page. Please ensure you are on a Claude.ai conversation page (https://claude.ai/chat/...) and refresh if needed.' + ); + } + throw sendError; + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error occurred'; + showStatus(message, 'error'); + } finally { + button.disabled = false; + } +} + +/** + * Handle browse conversations button + */ +function handleBrowseConversations(): void { + void chrome.tabs.create({ url: chrome.runtime.getURL('src/chrome/scripts/browse.html') }); +} + +/** + * Handle export all conversations + */ +async function handleExportAll(): Promise { + const button = getElement('exportAll'); + if (!button) return; + + button.disabled = true; + showStatus('Fetching all conversations...', 'info'); + + try { + const orgId = await getOrgId(); + + if (!orgId) { + throw new Error( + 'Organization ID not configured. Click the setup link above to configure it.' + ); + } + + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tab = tabs[0]; + + // Check if we're on Claude.ai + if (!tab?.url?.includes('claude.ai')) { + throw new Error( + 'Please navigate to a Claude.ai page first, or use "Browse All Conversations" instead.' + ); + } + + if (!tab.id) { + throw new Error('Could not identify active tab.'); + } + + const formatSelect = getElement('format'); + const metadataCheck = getElement('includeMetadata'); + + try { + const response = (await chrome.tabs.sendMessage(tab.id, { + action: 'exportAllConversations', + orgId, + format: formatSelect?.value ?? 'json', + includeMetadata: metadataCheck?.checked ?? true, + })) as ExtensionResponse; + + if (response?.success) { + if (response.warnings) { + showStatus(response.warnings, 'info'); + } else { + showStatus(`Exported ${response.count ?? 0} conversations!`, 'success'); + } + } else if (response?.error) { + // Only show error if there's an actual error message + console.error('Export failed:', response.error, response?.details); + showStatus(response.error, 'error'); + } else { + // No explicit success or error - assume download was triggered + showStatus('Download started...', 'success'); + } + } catch (sendError) { + // Handle "Could not establish connection" error + const errorMsg = sendError instanceof Error ? sendError.message : String(sendError); + if (errorMsg.includes('connection') || errorMsg.includes('Receiving end')) { + throw new Error( + 'Cannot connect to page. This feature requires a Claude.ai page (https://claude.ai/chat/... or similar). Try "Browse All Conversations" instead - it works from any tab.' + ); + } + throw sendError; + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error occurred'; + showStatus(message, 'error'); + } finally { + button.disabled = false; + } +} + +/** + * Initialize popup when DOM is loaded + */ +document.addEventListener('DOMContentLoaded', () => { + void initializePopup(); + setupOptionsLink(); + + const exportCurrentBtn = getElement('exportCurrent'); + const browseBtn = getElement('browseConversations'); + const exportAllBtn = getElement('exportAll'); + + exportCurrentBtn?.addEventListener('click', () => void handleExportCurrent()); + browseBtn?.addEventListener('click', handleBrowseConversations); + exportAllBtn?.addEventListener('click', () => void handleExportAll()); +}); diff --git a/src/firefox/manifest.json b/src/firefox/manifest.json index e617df3..ad3c4ea 100644 --- a/src/firefox/manifest.json +++ b/src/firefox/manifest.json @@ -14,7 +14,7 @@ "permissions": ["activeTab", "storage", "tabs", "https://claude.ai/*"], "browser_action": { - "default_popup": "src/popup.html", + "default_popup": "src/firefox/scripts/popup.html", "default_icon": { "16": "src/icon16.png", "48": "src/icon48.png", @@ -25,14 +25,14 @@ "content_scripts": [ { "matches": ["https://claude.ai/*"], - "js": ["src/content.ts"], + "js": ["src/firefox/scripts/content.ts"], "css": ["src/content-styles.css"], "run_at": "document_end" } ], "background": { - "scripts": ["src/background.ts"] + "scripts": ["src/firefox/scripts/background.ts"] }, "icons": { @@ -42,9 +42,9 @@ }, "options_ui": { - "page": "src/options.html", - "open_in_tab": true + "page": "src/firefox/scripts/options.html", + "browser_style": false }, - "web_accessible_resources": ["src/browse.html"] + "web_accessible_resources": ["src/firefox/scripts/browse.html"] } diff --git a/src/firefox/scripts/background.ts b/src/firefox/scripts/background.ts new file mode 100644 index 0000000..c9e813f --- /dev/null +++ b/src/firefox/scripts/background.ts @@ -0,0 +1,77 @@ +/** + * Background script for Claude Conversation Exporter + * Handles extension lifecycle events and content script injection + */ + +import type { ExtensionMessage, ExtensionResponse } from '../../types'; + +/** + * Handle extension installation + */ +browser.runtime.onInstalled.addListener(() => async () => { + console.log('Claude Conversation Exporter installed'); + + // Inject content script into already-open Claude.ai tabs + try { + const tabs = await browser.tabs.query({ url: 'https://claude.ai/*' }); + + for (const tab of tabs) { + if (tab.id) { + try { + await browser.scripting.executeScript({ + target: { tabId: tab.id }, + files: ['content.js'], + }); + } catch (err) { + console.log('Could not inject into tab', tab.id, err); + } + } + } + } catch (err) { + console.error('Error querying or injecting tabs:', err); + } +}); + +/** + * Handle messages from popup or other extension components + */ +browser.runtime.onMessage.addListener( + ( + request: ExtensionMessage, + _sender: browser.runtime.MessageSender + ): Promise | boolean => { + if (request.action === 'ensureContentScript') { + return (async () => { + try { + const tabs = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + + const activeTab = tabs[0]; + if (activeTab?.id) { + await browser.scripting.executeScript({ + target: { tabId: activeTab.id }, + files: ['content.js'], + }); + + return { success: true }; + } + + return { + success: false, + error: 'No active tab found', + }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + }; + } + })(); + } + + // Return false to indicate no response will be sent + return false; + } +); diff --git a/src/firefox/scripts/browse.html b/src/firefox/scripts/browse.html new file mode 100644 index 0000000..af354f7 --- /dev/null +++ b/src/firefox/scripts/browse.html @@ -0,0 +1,542 @@ + + + + + Browse Claude Conversations + + + +
+

Browse Claude Conversations

+
+ + +
+ + +
+ +
+ + +
+ +
Loading...
+ +
+ + +
+ + +
+
+ +
+
+
+
+
+
Loading conversations...
+
+
+
+
+ + + + +
+ + +
+
+

Exporting Conversations

+
+
Preparing export...
+
+
+
+
+
+ +
+
+ + + + diff --git a/src/firefox/scripts/browse.ts b/src/firefox/scripts/browse.ts new file mode 100644 index 0000000..3f2fbd4 --- /dev/null +++ b/src/firefox/scripts/browse.ts @@ -0,0 +1,688 @@ +/** + * Browse page script for Claude Conversation Exporter + * Displays all conversations in a searchable/filterable table + */ + +import JSZip from 'jszip'; +import type { Conversation, ConversationListItem, ExportFormat } from '../../types'; +import { + convertToMarkdown, + convertToText, + downloadFile, + generateFilename, + inferModel, +} from '../../utils'; + +/** + * Model display name mappings + */ +const MODEL_DISPLAY_NAMES: Record = { + 'claude-3-sonnet-20240229': 'Claude 3 Sonnet', + 'claude-3-opus-20240229': 'Claude 3 Opus', + 'claude-3-haiku-20240307': 'Claude 3 Haiku', + 'claude-3-5-sonnet-20240620': 'Claude 3.5 Sonnet', + 'claude-3-5-haiku-20241022': 'Claude 3.5 Haiku', + 'claude-3-5-sonnet-20241022': 'Claude 3.6 Sonnet', + 'claude-3-7-sonnet-20250219': 'Claude 3.7 Sonnet', + 'claude-sonnet-4-20250514': 'Claude Sonnet 4', + 'claude-opus-4-20250514': 'Claude Opus 4', + 'claude-opus-4-1-20250805': 'Claude Opus 4.1', + 'claude-sonnet-4-5-20250929': 'Claude Sonnet 4.5', +}; + +/** + * State management + */ +interface BrowseState { + allConversations: ConversationListItem[]; + filteredConversations: ConversationListItem[]; + orgId: string | null; + currentSort: string; + cancelExport: boolean; +} + +const state: BrowseState = { + allConversations: [], + filteredConversations: [], + orgId: null, + currentSort: 'updated_desc', + cancelExport: false, +}; + +/** + * Get HTML element by ID with type checking + */ +function getElement(id: string): T | null { + return document.getElementById(id) as T | null; +} + +/** + * Load organization ID from storage + */ +async function loadOrgId(): Promise { + console.log('[Browse] Loading org ID...'); + try { + const result = await browser.storage.sync.get(['organizationId']); + state.orgId = (result['organizationId'] as string) || null; + console.log('[Browse] Org ID loaded:', state.orgId); + + if (!state.orgId) { + console.warn('[Browse] No org ID configured'); + showError( + 'Organization ID not configured. Please configure it in the extension options.' + ); + } + } catch (error) { + console.error('[Browse] Error loading org ID:', error); + } +} + +/** + * Load all conversations from Claude API + */ +async function loadConversations(): Promise { + console.log('[Browse] loadConversations called, orgId:', state.orgId); + if (!state.orgId) { + console.warn('[Browse] No org ID, skipping conversation load'); + return; + } + + try { + console.log('[Browse] Fetching conversations from API...'); + const response = await fetch( + `https://claude.ai/api/organizations/${state.orgId}/chat_conversations`, + { + credentials: 'include', + headers: { + Accept: 'application/json', + }, + } + ); + + console.log('[Browse] API response status:', response.status); + if (!response.ok) { + throw new Error(`Failed to load conversations: ${response.status}`); + } + + const conversations = (await response.json()) as ConversationListItem[]; + console.log(`[Browse] Loaded ${conversations.length} conversations`); + + // Infer models for conversations with null model + state.allConversations = conversations.map((conv) => ({ + ...conv, + model: inferModel(conv), + })); + + // Extract unique models for filter + const models = [ + ...new Set( + state.allConversations.map((c) => c.model).filter((m): m is string => Boolean(m)) + ), + ].sort(); + populateModelFilter(models); + + // Apply initial sort and display + applyFiltersAndSort(); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('Error loading conversations:', error); + showError(`Failed to load conversations: ${errorMsg}`); + } +} + +/** + * Populate model filter dropdown + */ +function populateModelFilter(models: string[]): void { + const modelFilter = getElement('modelFilter'); + if (!modelFilter) return; + + modelFilter.innerHTML = ''; + + for (const model of models) { + const option = document.createElement('option'); + option.value = model; + option.textContent = formatModelName(model); + modelFilter.appendChild(option); + } +} + +/** + * Format model name for display + */ +function formatModelName(model: string): string { + return MODEL_DISPLAY_NAMES[model] ?? model; +} + +/** + * Get model badge CSS class + */ +function getModelBadgeClass(model: string): string { + if (model.includes('sonnet')) return 'sonnet'; + if (model.includes('opus')) return 'opus'; + if (model.includes('haiku')) return 'haiku'; + return ''; +} + +/** + * Apply filters and sorting + */ +function applyFiltersAndSort(): void { + const searchInput = getElement('searchInput'); + const modelFilter = getElement('modelFilter'); + + const searchTerm = searchInput?.value.toLowerCase() ?? ''; + const modelFilterValue = modelFilter?.value ?? ''; + + // Filter conversations + state.filteredConversations = state.allConversations.filter((conv) => { + const matchesSearch = + !searchTerm || + conv.name.toLowerCase().includes(searchTerm) || + ('summary' in conv && + typeof conv['summary'] === 'string' && + conv['summary'].toLowerCase().includes(searchTerm)); + + const matchesModel = !modelFilterValue || conv.model === modelFilterValue; + + return matchesSearch && matchesModel; + }); + + // Sort conversations + sortConversations(); + + // Update display + displayConversations(); + updateStats(); +} + +/** + * Sort conversations based on current sort setting + */ +function sortConversations(): void { + const [field, direction] = state.currentSort.split('_'); + + state.filteredConversations.sort((a, b) => { + let aVal: string | Date; + let bVal: string | Date; + + switch (field) { + case 'name': + aVal = a.name.toLowerCase(); + bVal = b.name.toLowerCase(); + break; + case 'created': + aVal = new Date(a.created_at); + bVal = new Date(b.created_at); + break; + case 'updated': + aVal = new Date(a.updated_at); + bVal = new Date(b.updated_at); + break; + default: + return 0; + } + + if (direction === 'asc') { + return aVal > bVal ? 1 : -1; + } else { + return aVal < bVal ? 1 : -1; + } + }); +} + +/** + * Display conversations in table + */ +function displayConversations(): void { + console.log( + '[Browse] displayConversations called, filtered count:', + state.filteredConversations.length + ); + const tableContent = getElement('tableContent'); + if (!tableContent) { + console.error('[Browse] tableContent element not found!'); + return; + } + + if (state.filteredConversations.length === 0) { + console.log('[Browse] No conversations to display'); + tableContent.innerHTML = '
No conversations found
'; + return; + } + + console.log( + '[Browse] Rendering table with', + state.filteredConversations.length, + 'conversations' + ); + + let html = ` + + + + + + + + + + + + `; + + for (const conv of state.filteredConversations) { + const updatedDate = new Date(conv.updated_at).toLocaleDateString(); + const createdDate = new Date(conv.created_at).toLocaleDateString(); + const modelBadgeClass = getModelBadgeClass(conv.model ?? ''); + + html += ` + + + + + + + + `; + } + + html += ` + +
NameLast UpdatedCreatedModelActions
+ + ${updatedDate}${createdDate} + + ${formatModelName(conv.model ?? '')} + + +
+ + +
+
+ `; + + tableContent.innerHTML = html; + + // Add export button listeners + const exportButtons = document.querySelectorAll('.btn-export'); + exportButtons.forEach((btn) => { + btn.addEventListener('click', () => { + const id = btn.dataset['id']; + const name = btn.dataset['name']; + if (id && name) { + void exportConversation(id, name); + } + }); + }); + + // Add view button listeners + const viewButtons = document.querySelectorAll('.btn-view'); + viewButtons.forEach((btn) => { + btn.addEventListener('click', () => { + const conversationId = btn.dataset['id']; + if (conversationId) { + window.open(`https://claude.ai/chat/${conversationId}`, '_blank'); + } + }); + }); + + // Enable export all button + const exportAllBtn = getElement('exportAllBtn'); + if (exportAllBtn) { + exportAllBtn.disabled = false; + } +} + +/** + * Update statistics display + */ +function updateStats(): void { + const stats = getElement('stats'); + if (!stats) return; + + stats.textContent = `Showing ${state.filteredConversations.length} of ${state.allConversations.length} conversations`; +} + +/** + * Export single conversation + */ +async function exportConversation( + conversationId: string, + conversationName: string +): Promise { + const formatSelect = getElement('exportFormat'); + const metadataCheck = getElement('includeMetadata'); + + const format = (formatSelect?.value as ExportFormat) || 'json'; + const includeMetadata = metadataCheck?.checked ?? true; + + try { + showToast(`Exporting ${conversationName}...`); + + const response = await fetch( + `https://claude.ai/api/organizations/${state.orgId}/chat_conversations/${conversationId}?tree=True&rendering_mode=messages&render_all_tools=true`, + { + credentials: 'include', + headers: { + Accept: 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch conversation: ${response.status}`); + } + + const data = (await response.json()) as Conversation; + + // Infer model if null + data.model = inferModel(data); + + let content: string; + let filename: string; + let type: string; + + switch (format) { + case 'markdown': + content = convertToMarkdown(data, includeMetadata); + filename = generateFilename(conversationName, 'markdown', data.uuid); + type = 'text/markdown'; + break; + case 'text': + content = convertToText(data, includeMetadata); + filename = generateFilename(conversationName, 'text', data.uuid); + type = 'text/plain'; + break; + default: + content = JSON.stringify(data, null, 2); + filename = generateFilename(conversationName, 'json', data.uuid); + type = 'application/json'; + } + + downloadFile(content, filename, type); + showToast(`Exported: ${conversationName}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('Export error:', error); + showToast(`Failed to export: ${errorMsg}`, true); + } +} + +/** + * Export all filtered conversations as ZIP + */ +async function exportAllFiltered(): Promise { + const formatSelect = getElement('exportFormat'); + const metadataCheck = getElement('includeMetadata'); + const button = getElement('exportAllBtn'); + + const format = (formatSelect?.value as ExportFormat) || 'json'; + const includeMetadata = metadataCheck?.checked ?? true; + + if (!button) return; + + button.disabled = true; + button.textContent = 'Preparing...'; + + // Show progress modal + const progressModal = getElement('progressModal'); + const progressBar = getElement('progressBar'); + const progressText = getElement('progressText'); + const progressStats = getElement('progressStats'); + + if (!progressModal || !progressBar || !progressText || !progressStats) { + showToast('Error: Progress UI elements not found', true); + return; + } + + progressModal.style.display = 'block'; + state.cancelExport = false; + + const cancelButton = getElement('cancelExport'); + if (cancelButton) { + cancelButton.onclick = () => { + state.cancelExport = true; + progressText.textContent = 'Cancelling...'; + }; + } + + try { + const zip = new JSZip(); + const total = state.filteredConversations.length; + let completed = 0; + let failed = 0; + const failedConversations: string[] = []; + + progressText.textContent = `Exporting ${total} conversations...`; + + // Process conversations in batches + const batchSize = 3; + for (let i = 0; i < total; i += batchSize) { + if (state.cancelExport) break; + + const batch = state.filteredConversations.slice(i, Math.min(i + batchSize, total)); + const promises = batch.map(async (conv) => { + try { + const response = await fetch( + `https://claude.ai/api/organizations/${state.orgId}/chat_conversations/${conv.uuid}?tree=True&rendering_mode=messages&render_all_tools=true`, + { + credentials: 'include', + headers: { + Accept: 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = (await response.json()) as Conversation; + data.model = inferModel(data); + + let content: string; + let filename: string; + const safeName = conv.name.replace(/[<>:"/\\|?*]/g, '_'); + + switch (format) { + case 'markdown': + content = convertToMarkdown(data, includeMetadata); + filename = `${safeName}.md`; + break; + case 'text': + content = convertToText(data, includeMetadata); + filename = `${safeName}.txt`; + break; + default: + content = JSON.stringify(data, null, 2); + filename = `${safeName}.json`; + } + + zip.file(filename, content); + completed++; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error(`Failed to export ${conv.name}:`, errorMsg); + failed++; + failedConversations.push(conv.name); + } + }); + + await Promise.all(promises); + + // Update progress + const progress = Math.round(((completed + failed) / total) * 100); + progressBar.style.width = `${progress}%`; + progressStats.textContent = `${completed} succeeded, ${failed} failed out of ${total}`; + + // Small delay between batches + if (i + batchSize < total && !state.cancelExport) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + + if (state.cancelExport) { + progressModal.style.display = 'none'; + showToast('Export cancelled', true); + return; + } + + // Add summary file + const summary = { + export_date: new Date().toISOString(), + total_conversations: total, + successful_exports: completed, + failed_exports: failed, + failed_conversations: failedConversations, + format: format, + include_metadata: includeMetadata, + }; + zip.file('export_summary.json', JSON.stringify(summary, null, 2)); + + // Generate and download ZIP + progressText.textContent = 'Creating ZIP file...'; + const blob = await zip.generateAsync( + { + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { + level: 6, + }, + }, + (metadata) => { + const zipProgress = Math.round(metadata.percent); + progressBar.style.width = `${zipProgress}%`; + } + ); + + // Download the ZIP file + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `claude-conversations-${ + new Date().toISOString().split('T')[0] ?? 'export' + }.zip`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + progressModal.style.display = 'none'; + + if (failed > 0) { + showToast( + `Exported ${completed} of ${total} conversations (${failed} failed). Check export_summary.json in the ZIP for details.` + ); + } else { + showToast(`Successfully exported all ${completed} conversations!`); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('Export error:', error); + progressModal.style.display = 'none'; + showToast(`Export failed: ${errorMsg}`, true); + } finally { + button.disabled = false; + button.textContent = 'Export All'; + } +} + +/** + * Show error message + */ +function showError(message: string): void { + const tableContent = getElement('tableContent'); + if (!tableContent) return; + + tableContent.innerHTML = `
${message}
`; +} + +/** + * Show toast notification + */ +function showToast(message: string, isError = false): void { + const toast = getElement('toast'); + if (!toast) return; + + toast.textContent = message; + toast.style.background = isError ? '#d32f2f' : '#333'; + toast.classList.add('show'); + + setTimeout(() => { + toast.classList.remove('show'); + }, 3000); +} + +/** + * Setup event listeners + */ +function setupEventListeners(): void { + // Search input + const searchInput = getElement('searchInput'); + const searchBox = getElement('searchBox'); + searchInput?.addEventListener('input', (e) => { + const target = e.target as HTMLInputElement; + if (searchBox) { + if (target.value) { + searchBox.classList.add('has-text'); + } else { + searchBox.classList.remove('has-text'); + } + } + applyFiltersAndSort(); + }); + + // Clear search + const clearSearch = getElement('clearSearch'); + clearSearch?.addEventListener('click', () => { + if (searchInput) searchInput.value = ''; + searchBox?.classList.remove('has-text'); + applyFiltersAndSort(); + }); + + // Model filter + const modelFilter = getElement('modelFilter'); + modelFilter?.addEventListener('change', applyFiltersAndSort); + + // Sort dropdown + const sortBy = getElement('sortBy'); + sortBy?.addEventListener('change', (e) => { + const target = e.target as HTMLSelectElement; + state.currentSort = target.value; + applyFiltersAndSort(); + }); + + // Export all button + const exportAllBtn = getElement('exportAllBtn'); + exportAllBtn?.addEventListener('click', () => { + void exportAllFiltered(); + }); +} + +/** + * Initialize on page load + */ +console.log('[Browse] Script loaded, waiting for DOMContentLoaded...'); +document.addEventListener('DOMContentLoaded', () => { + console.log('[Browse] DOMContentLoaded fired, initializing...'); + void (async () => { + try { + await loadOrgId(); + await loadConversations(); + setupEventListeners(); + console.log('[Browse] Initialization complete'); + } catch (error) { + console.error('[Browse] Initialization error:', error); + } + })(); +}); diff --git a/src/firefox/scripts/content.ts b/src/firefox/scripts/content.ts new file mode 100644 index 0000000..a423983 --- /dev/null +++ b/src/firefox/scripts/content.ts @@ -0,0 +1,245 @@ +/** + * Content script for Claude Conversation Exporter + * Runs on claude.ai pages to handle conversation exports + */ + +import type { + Conversation, + ConversationListItem, + ExtensionMessage, + ExtensionResponse, + ExportFormat, +} from '../../types'; +import { + convertToMarkdown, + convertToText, + downloadFile, + generateFilename, + inferModel, +} from '../../utils'; + +/** + * Fetch a single conversation from Claude API + */ +async function fetchConversation( + orgId: string, + conversationId: string +): Promise { + const url = `https://claude.ai/api/organizations/${orgId}/chat_conversations/${conversationId}?tree=True&rendering_mode=messages&render_all_tools=true`; + + const response = await fetch(url, { + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch conversation: ${response.status}`); + } + + return (await response.json()) as Conversation; +} + +/** + * Fetch all conversations from Claude API + */ +async function fetchAllConversations(orgId: string): Promise { + const url = `https://claude.ai/api/organizations/${orgId}/chat_conversations`; + + const response = await fetch(url, { + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch conversations: ${response.status}`); + } + + return (await response.json()) as ConversationListItem[]; +} + +/** + * Export a single conversation + */ +async function exportConversation( + orgId: string, + conversationId: string, + format: ExportFormat, + includeMetadata: boolean +): Promise { + console.log('Fetching conversation:', conversationId); + const data = await fetchConversation(orgId, conversationId); + + console.log('Conversation data fetched successfully:', data); + + // Infer model if null + const modelName = inferModel(data); + data.model = modelName; + + let content: string; + let filename: string; + let type: string; + + switch (format) { + case 'markdown': + content = convertToMarkdown(data, includeMetadata); + filename = generateFilename(data.name || 'untitled', 'markdown', data.uuid); + type = 'text/markdown'; + break; + case 'text': + content = convertToText(data, includeMetadata); + filename = generateFilename(data.name || 'untitled', 'text', data.uuid); + type = 'text/plain'; + break; + default: + content = JSON.stringify(data, null, 2); + filename = generateFilename(data.name || 'untitled', 'json', data.uuid); + type = 'application/json'; + } + + console.log('Downloading file:', filename); + downloadFile(content, filename, type); +} + +/** + * Export all conversations + */ +async function exportAllConversations( + orgId: string, + format: ExportFormat, + includeMetadata: boolean +): Promise<{ count: number; warnings?: string }> { + const conversations = await fetchAllConversations(orgId); + console.log(`Fetched ${conversations.length} conversations`); + + if (format === 'json') { + // For JSON, export as a single file with all conversations + const filename = `claude-all-conversations-${ + new Date().toISOString().split('T')[0] ?? 'export' + }.json`; + console.log('Downloading all conversations as JSON:', filename); + downloadFile(JSON.stringify(conversations, null, 2), filename, 'application/json'); + return { count: conversations.length }; + } + + // For other formats, create individual files + let count = 0; + const errors: string[] = []; + + for (const conv of conversations) { + try { + console.log( + `Fetching full conversation ${count + 1}/${conversations.length}: ${conv.uuid}` + ); + const fullConv = await fetchConversation(orgId, conv.uuid); + + // Infer model if null + fullConv.model = inferModel(fullConv); + + let content: string; + let filename: string; + let type: string; + + if (format === 'markdown') { + content = convertToMarkdown(fullConv, includeMetadata); + filename = generateFilename(conv.name || 'untitled', 'markdown', conv.uuid); + type = 'text/markdown'; + } else { + content = convertToText(fullConv, includeMetadata); + filename = generateFilename(conv.name || 'untitled', 'text', conv.uuid); + type = 'text/plain'; + } + + downloadFile(content, filename, type); + count++; + + // Add a small delay to avoid overwhelming the API + await new Promise((resolve) => setTimeout(resolve, 500)); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error(`Failed to export conversation ${conv.uuid}:`, errorMsg); + errors.push(`${conv.name || conv.uuid}: ${errorMsg}`); + } + } + + if (errors.length > 0) { + console.warn('Some conversations failed to export:', errors); + return { + count, + warnings: `Exported ${count}/${conversations.length} conversations. Some failed: ${errors.join('; ')}`, + }; + } + + return { count }; +} + +/** + * Handle messages from popup or background script + */ +browser.runtime.onMessage.addListener( + ( + request: ExtensionMessage, + _sender: browser.runtime.MessageSender + ): Promise | boolean => { + if (request.action === 'exportConversation') { + console.log('Export conversation request received:', request); + + return (async () => { + try { + await exportConversation( + request.orgId, + request.conversationId, + request.format, + request.includeMetadata + ); + return { success: true }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + const errorStack = error instanceof Error ? error.stack : undefined; + console.error('Export conversation error:', error); + return { + success: false, + error: errorMsg, + details: errorStack, + }; + } + })(); + } + + if (request.action === 'exportAllConversations') { + console.log('Export all conversations request received:', request); + + return (async () => { + try { + const result = await exportAllConversations( + request.orgId, + request.format, + request.includeMetadata + ); + return { + success: true, + count: result.count, + warnings: result.warnings, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + const errorStack = error instanceof Error ? error.stack : undefined; + console.error('Export all conversations error:', error); + return { + success: false, + error: errorMsg, + details: errorStack, + }; + } + })(); + } + + // Return false to indicate no response will be sent + return false; + } +); + +console.log('Claude Conversation Exporter content script loaded'); diff --git a/src/firefox/scripts/options.html b/src/firefox/scripts/options.html new file mode 100644 index 0000000..6dfed7a --- /dev/null +++ b/src/firefox/scripts/options.html @@ -0,0 +1,139 @@ + + + + + Claude Exporter Settings + + + +

Claude Conversation Exporter Settings

+ +
+

How to find your Organization ID:

+
    +
  1. + Go to + claude.ai/settings/account +
  2. +
  3. + Copy your Organization ID (e.g., + xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) +
  4. +
  5. Paste it below and click Save
  6. +
+
+ +
+ + +
+ This is a UUID that looks like: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +
+ +
+
+ +
+

Test Your Settings

+

After saving your Organization ID, you can test if it works:

+ +
+
+ + + + diff --git a/src/firefox/scripts/options.ts b/src/firefox/scripts/options.ts new file mode 100644 index 0000000..b5b7add --- /dev/null +++ b/src/firefox/scripts/options.ts @@ -0,0 +1,160 @@ +/** + * Options page script for Claude Conversation Exporter + * Handles extension configuration and settings + */ + +type StatusType = 'success' | 'error' | 'info'; + +/** + * Get HTML element by ID with type checking + */ +function getElement(id: string): T | null { + return document.getElementById(id) as T | null; +} + +/** + * Show status message + */ +function showStatus(elementId: string, message: string, type: StatusType): void { + const statusEl = getElement(elementId); + if (!statusEl) return; + + statusEl.textContent = message; + statusEl.className = `status ${type}`; +} + +/** + * Hide status message + */ +function hideStatus(elementId: string): void { + const statusEl = getElement(elementId); + if (!statusEl) return; + + statusEl.className = 'status'; + statusEl.textContent = ''; +} + +/** + * Load saved settings on page load + */ +async function loadSettings(): Promise { + try { + const result = await browser.storage.sync.get(['organizationId']); + const orgId = result['organizationId'] as string | undefined; + + if (orgId) { + const orgIdInput = getElement('orgId'); + if (orgIdInput) { + orgIdInput.value = orgId; + } + showStatus('status', 'Organization ID loaded from saved settings', 'success'); + setTimeout(() => hideStatus('status'), 2000); + } + } catch (error) { + console.error('Error loading settings:', error); + } +} + +/** + * Save settings + */ +async function saveSettings(): Promise { + const orgIdInput = getElement('orgId'); + if (!orgIdInput) return; + + const orgId = orgIdInput.value.trim(); + + if (!orgId) { + showStatus('status', 'Please enter an Organization ID', 'error'); + return; + } + + // Validate UUID format + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(orgId)) { + showStatus( + 'status', + 'Invalid Organization ID format. It should be a UUID like: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'error' + ); + return; + } + + try { + await browser.storage.sync.set({ organizationId: orgId }); + showStatus('status', 'Settings saved successfully!', 'success'); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + showStatus('status', `Error saving settings: ${errorMsg}`, 'error'); + } +} + +/** + * Test connection to Claude API + */ +async function testConnection(): Promise { + const orgIdInput = getElement('orgId'); + if (!orgIdInput) return; + + const orgId = orgIdInput.value.trim(); + + if (!orgId) { + showStatus('testStatus', 'Please save an Organization ID first', 'error'); + return; + } + + showStatus('testStatus', 'Testing connection...', 'info'); + + try { + const response = await fetch( + `https://claude.ai/api/organizations/${orgId}/chat_conversations`, + { + credentials: 'include', + headers: { + Accept: 'application/json', + }, + } + ); + + if (response.ok) { + const data = (await response.json()) as unknown[]; + showStatus('testStatus', `Success! Found ${data.length} conversations.`, 'success'); + } else if (response.status === 401) { + showStatus( + 'testStatus', + 'Not authenticated. Please make sure you are logged into Claude.ai', + 'error' + ); + } else if (response.status === 403) { + showStatus( + 'testStatus', + 'Access denied. The Organization ID might be incorrect.', + 'error' + ); + } else { + showStatus( + 'testStatus', + `Connection failed with status: ${response.status}`, + 'error' + ); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + showStatus('testStatus', `Connection error: ${errorMsg}`, 'error'); + } +} + +/** + * Initialize options page + */ +document.addEventListener('DOMContentLoaded', () => { + // Load saved settings + void loadSettings(); + + // Set up event listeners + const saveBtn = getElement('saveBtn'); + const testBtn = getElement('testBtn'); + + saveBtn?.addEventListener('click', () => void saveSettings()); + testBtn?.addEventListener('click', () => void testConnection()); +}); diff --git a/src/firefox/scripts/popup.html b/src/firefox/scripts/popup.html new file mode 100644 index 0000000..655fc96 --- /dev/null +++ b/src/firefox/scripts/popup.html @@ -0,0 +1,121 @@ + + + + + + + +
+ Claude Conversation Exporter +
+ +
+ + +
+ + + +
+ + + + + +
+
+ + + + diff --git a/src/firefox/scripts/popup.ts b/src/firefox/scripts/popup.ts new file mode 100644 index 0000000..97b1ef7 --- /dev/null +++ b/src/firefox/scripts/popup.ts @@ -0,0 +1,264 @@ +// @ts-nocheck - File is compiled by Vite, not included in tsconfig.json +/** + * Firefox Popup Script + * Handles user interactions in the extension popup + */ + +import type { ExtensionMessage, ExtensionResponse } from '../../types'; + +/** + * Get organization ID from storage + */ +async function getOrgId(): Promise { + const result = await browser.storage.sync.get(['organizationId']); + return result['organizationId'] as string | undefined; +} + +/** + * Get current conversation ID from active tab URL + */ +async function getCurrentConversationId(): Promise { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const tab = tabs[0]; + + if (!tab?.url) { + return null; + } + + const url = new URL(tab.url); + const match = /\/chat\/([a-f0-9-]+)/.exec(url.pathname); + return match?.[1] ?? null; +} + +/** + * Show status message to user + */ +function showStatus(message: string, type: 'info' | 'success' | 'error' = 'info'): void { + const statusEl = document.getElementById('status'); + if (!statusEl) return; + + statusEl.className = `status ${type}`; + statusEl.textContent = message; + + if (type === 'success') { + setTimeout(() => { + statusEl.textContent = ''; + statusEl.className = ''; + }, 3000); + } +} + +/** + * Get HTML element by ID with type checking + */ +function getElement(id: string): T | null { + return document.getElementById(id) as T | null; +} + +/** + * Check if org ID is configured on popup load + */ +async function initializePopup(): Promise { + const orgId = await getOrgId(); + + if (!orgId) { + const setupNotice = getElement('setupNotice'); + if (setupNotice) { + setupNotice.style.display = 'block'; + } + + const exportCurrentBtn = getElement('exportCurrent'); + const exportAllBtn = getElement('exportAll'); + + if (exportCurrentBtn) exportCurrentBtn.disabled = true; + if (exportAllBtn) exportAllBtn.disabled = true; + } +} + +/** + * Handle options link click + */ +function setupOptionsLink(): void { + const openOptionsBtn = getElement('openOptions'); + openOptionsBtn?.addEventListener('click', (e) => { + e.preventDefault(); + void browser.runtime.openOptionsPage(); + }); +} + +/** + * Handle export current conversation + */ +async function handleExportCurrent(): Promise { + const button = getElement('exportCurrent'); + if (!button) return; + + button.disabled = true; + showStatus('Fetching conversation...', 'info'); + + try { + const orgId = await getOrgId(); + const conversationId = await getCurrentConversationId(); + + if (!orgId) { + throw new Error( + 'Organization ID not configured. Click the setup link above to configure it.' + ); + } + + if (!conversationId) { + throw new Error( + 'Could not detect a conversation ID. Make sure you are on a Claude.ai conversation page.' + ); + } + + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const tab = tabs[0]; + + // Check if we're on Claude.ai + if (!tab?.url?.includes('claude.ai')) { + throw new Error('Please navigate to a Claude.ai conversation page first.'); + } + + if (!tab.id) { + throw new Error('Could not identify active tab.'); + } + + const formatSelect = getElement('format'); + const metadataCheck = getElement('includeMetadata'); + + try { + const response = (await browser.tabs.sendMessage(tab.id, { + action: 'exportConversation', + conversationId, + orgId, + format: formatSelect?.value ?? 'json', + includeMetadata: metadataCheck?.checked ?? true, + })) as ExtensionResponse; + + if (response?.success) { + showStatus('Conversation exported successfully!', 'success'); + } else if (response?.error) { + // Only show error if there's an actual error message + console.error('Export failed:', response.error, response?.details); + showStatus(response.error, 'error'); + } else { + // No explicit success or error - assume download was triggered + showStatus('Download started...', 'success'); + } + } catch (sendError) { + // Handle "Could not establish connection" error + const errorMsg = sendError instanceof Error ? sendError.message : String(sendError); + if (errorMsg.includes('connection') || errorMsg.includes('Receiving end')) { + throw new Error( + 'Cannot connect to page. Please ensure you are on a Claude.ai conversation page (https://claude.ai/chat/...) and refresh if needed.' + ); + } + throw sendError; + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error occurred'; + showStatus(message, 'error'); + } finally { + button.disabled = false; + } +} + +/** + * Handle browse conversations button + */ +function handleBrowseConversations(): void { + void browser.tabs.create({ url: browser.runtime.getURL('src/firefox/scripts/browse.html') }); +} + +/** + * Handle export all conversations + */ +async function handleExportAll(): Promise { + const button = getElement('exportAll'); + if (!button) return; + + button.disabled = true; + showStatus('Fetching all conversations...', 'info'); + + try { + const orgId = await getOrgId(); + + if (!orgId) { + throw new Error( + 'Organization ID not configured. Click the setup link above to configure it.' + ); + } + + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const tab = tabs[0]; + + // Check if we're on Claude.ai + if (!tab?.url?.includes('claude.ai')) { + throw new Error( + 'Please navigate to a Claude.ai page first, or use "Browse All Conversations" instead.' + ); + } + + if (!tab.id) { + throw new Error('Could not identify active tab.'); + } + + const formatSelect = getElement('format'); + const metadataCheck = getElement('includeMetadata'); + + try { + const response = (await browser.tabs.sendMessage(tab.id, { + action: 'exportAllConversations', + orgId, + format: formatSelect?.value ?? 'json', + includeMetadata: metadataCheck?.checked ?? true, + })) as ExtensionResponse; + + if (response?.success) { + if (response.warnings) { + showStatus(response.warnings, 'info'); + } else { + showStatus(`Exported ${response.count ?? 0} conversations!`, 'success'); + } + } else if (response?.error) { + // Only show error if there's an actual error message + console.error('Export failed:', response.error, response?.details); + showStatus(response.error, 'error'); + } else { + // No explicit success or error - assume download was triggered + showStatus('Download started...', 'success'); + } + } catch (sendError) { + // Handle "Could not establish connection" error + const errorMsg = sendError instanceof Error ? sendError.message : String(sendError); + if (errorMsg.includes('connection') || errorMsg.includes('Receiving end')) { + throw new Error( + 'Cannot connect to page. This feature requires a Claude.ai page (https://claude.ai/chat/... or similar). Try "Browse All Conversations" instead - it works from any tab.' + ); + } + throw sendError; + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error occurred'; + showStatus(message, 'error'); + } finally { + button.disabled = false; + } +} + +/** + * Initialize popup when DOM is loaded + */ +document.addEventListener('DOMContentLoaded', () => { + void initializePopup(); + setupOptionsLink(); + + const exportCurrentBtn = getElement('exportCurrent'); + const browseBtn = getElement('browseConversations'); + const exportAllBtn = getElement('exportAll'); + + exportCurrentBtn?.addEventListener('click', () => void handleExportCurrent()); + browseBtn?.addEventListener('click', handleBrowseConversations); + exportAllBtn?.addEventListener('click', () => void handleExportAll()); +}); diff --git a/src/tests/README.md b/src/tests/README.md index 1b99992..8b1ae0d 100644 --- a/src/tests/README.md +++ b/src/tests/README.md @@ -7,6 +7,8 @@ This directory contains unit and integration tests for the Claude Conversation E - **`utils.test.ts`** - Unit tests for utility functions (model inference, conversion, filename generation, etc.) - **`types.test.ts`** - Type definition validation tests - **`integration.test.ts`** - Integration tests for complete workflows +- **`edgeCases.test.ts`** - Edge case and error handling tests +- **`build.test.ts`** - Build output verification tests (checks dist files and content script) - **`mockData.ts`** - Shared mock data and fixtures for tests - **`setup.ts`** - Vitest setup and browser API mocks diff --git a/src/tests/build.test.ts b/src/tests/build.test.ts new file mode 100644 index 0000000..233e662 --- /dev/null +++ b/src/tests/build.test.ts @@ -0,0 +1,224 @@ +/** + * Build verification tests + * These tests verify that the build process produces correct output + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync, statSync } from 'fs'; +import { resolve } from 'path'; + +const DIST_FIREFOX = resolve(__dirname, '../../dist/firefox'); +const DIST_CHROME = resolve(__dirname, '../../dist/chrome'); + +describe('Build Output Verification', () => { + describe('Firefox Build', () => { + it('should have all required files', () => { + if (!existsSync(DIST_FIREFOX)) { + console.warn('Firefox dist folder not found - run "pnpm build:firefox" first'); + return; + } + + const requiredFiles = [ + 'manifest.json', + 'background.js', + 'src/firefox/scripts/content.js', + 'src/firefox/scripts/popup.html', + 'src/firefox/scripts/popup.js', + 'src/firefox/scripts/popup-header.png', + 'src/firefox/scripts/options.html', + 'src/firefox/scripts/options.js', + 'src/firefox/scripts/browse.html', + 'src/firefox/scripts/browse.js', + 'src/content-styles.css', + 'src/icon16.png', + 'src/icon48.png', + 'src/icon128.png', + ]; + + for (const file of requiredFiles) { + const filePath = resolve(DIST_FIREFOX, file); + expect(existsSync(filePath), `${file} should exist`).toBe(true); + } + }); + + it('should have a properly built content script (not a stub)', () => { + if (!existsSync(DIST_FIREFOX)) { + console.warn('Firefox dist folder not found - run "pnpm build:firefox" first'); + return; + } + + const contentScriptPath = resolve(DIST_FIREFOX, 'src/firefox/scripts/content.js'); + if (!existsSync(contentScriptPath)) { + throw new Error('Content script not found'); + } + + const stats = statSync(contentScriptPath); + const content = readFileSync(contentScriptPath, 'utf-8'); + + // Content script should be substantial (at least 3KB when minified) + expect(stats.size).toBeGreaterThan(3000); + + // Should contain actual code, not just a dynamic import wrapper + expect(content).not.toMatch(/^.*await import.*$/); + expect(content).not.toBe( + '(async()=>{await import(chrome.runtime.getURL("src/content.js"))})();' + ); + + // Should contain key functionality + expect(content).toContain('browser.runtime.onMessage.addListener'); + expect(content).toContain('exportConversation'); + expect(content).toContain('exportAllConversations'); + expect(content).toContain('Claude Conversation Exporter content script loaded'); + }); + + it('should have a valid manifest.json', () => { + if (!existsSync(DIST_FIREFOX)) { + console.warn('Firefox dist folder not found - run "pnpm build:firefox" first'); + return; + } + + const manifestPath = resolve(DIST_FIREFOX, 'manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { + manifest_version: number; + name: string; + permissions?: string[]; + content_scripts?: { matches: string[]; js: string[] }[]; + }; + + expect(manifest.manifest_version).toBe(2); + expect(manifest.name).toBe('Claude Conversation Exporter'); + expect(manifest.permissions).toContain('storage'); + expect(manifest.content_scripts).toBeDefined(); + expect(manifest.content_scripts?.[0]?.matches).toContain('https://claude.ai/*'); + expect(manifest.content_scripts?.[0]?.js).toContain( + 'src/firefox/scripts/content.js' + ); + }); + }); + + describe('Chrome Build', () => { + it('should have all required files when built', () => { + if (!existsSync(DIST_CHROME)) { + console.warn('Chrome dist folder not found - run "pnpm build:chrome" first'); + return; + } + + const requiredFiles = [ + 'manifest.json', + 'serviceWorker.js', + 'src/chrome/scripts/background.js', + 'src/chrome/scripts/popup.html', + 'src/chrome/scripts/popup.js', + 'src/chrome/scripts/popup-header.png', + 'src/chrome/scripts/options.html', + 'src/chrome/scripts/options.js', + 'src/chrome/scripts/browse.html', + 'src/chrome/scripts/browse.js', + 'src/chrome/scripts/content.js', + 'src/content-styles.css', + 'src/icon16.png', + 'src/icon48.png', + 'src/icon128.png', + ]; + + for (const file of requiredFiles) { + const filePath = resolve(DIST_CHROME, file); + expect(existsSync(filePath), `${file} should exist`).toBe(true); + } + }); + + it('should have a valid manifest.json with MV3', () => { + if (!existsSync(DIST_CHROME)) { + console.warn('Chrome dist folder not found - run "pnpm build:chrome" first'); + return; + } + + const manifestPath = resolve(DIST_CHROME, 'manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { + manifest_version: number; + name: string; + permissions?: string[]; + content_scripts?: { matches: string[]; js: string[] }[]; + }; + + expect(manifest.manifest_version).toBe(3); + expect(manifest.name).toBe('Claude Conversation Exporter'); + expect(manifest.permissions).toContain('storage'); + expect(manifest.content_scripts).toBeDefined(); + expect(manifest.content_scripts?.[0]?.matches).toContain('https://claude.ai/*'); + expect(manifest.content_scripts?.[0]?.js).toContain( + 'src/chrome/scripts/content.js' + ); + }); + + it('should have a properly built Chrome content script', () => { + if (!existsSync(DIST_CHROME)) { + console.warn('Chrome dist folder not found - run "pnpm build:chrome" first'); + return; + } + + const contentScriptPath = resolve(DIST_CHROME, 'src/chrome/scripts/content.js'); + if (!existsSync(contentScriptPath)) { + throw new Error('Content script not found'); + } + + const stats = statSync(contentScriptPath); + const content = readFileSync(contentScriptPath, 'utf-8'); + + // Content script should be substantial (at least 3KB when minified) + expect(stats.size).toBeGreaterThan(3000); + + // Should contain actual code, not just a dynamic import wrapper + expect(content).not.toMatch(/^.*await import.*$/); + + // Should use chrome.* API + expect(content).toContain('chrome.runtime.onMessage'); + expect(content).toContain('exportConversation'); + expect(content).toContain('exportAllConversations'); + }); + }); +}); + +describe('Source Code Quality', () => { + it('should have TypeScript source files with proper typing', () => { + const typesFile = resolve(__dirname, '../types.ts'); + const utilsFile = resolve(__dirname, '../utils.ts'); + const firefoxContentFile = resolve(__dirname, '../firefox/scripts/content.ts'); + const chromeContentFile = resolve(__dirname, '../chrome/scripts/content.ts'); + + expect(existsSync(typesFile)).toBe(true); + expect(existsSync(utilsFile)).toBe(true); + expect(existsSync(firefoxContentFile)).toBe(true); + expect(existsSync(chromeContentFile)).toBe(true); + + // Verify types file exports key types + const typesContent = readFileSync(typesFile, 'utf-8'); + expect(typesContent).toContain('export interface Conversation'); + expect(typesContent).toContain('export interface ConversationListItem'); + expect(typesContent).toContain('export type ExportFormat'); + }); + + it('should have proper browser API usage in Firefox content script', () => { + const contentFile = resolve(__dirname, '../firefox/scripts/content.ts'); + const content = readFileSync(contentFile, 'utf-8'); + + // Should use browser.* API (Firefox compatible) + expect(content).toContain('browser.runtime.onMessage'); + + // Should have message handlers for both export actions + expect(content).toContain('exportConversation'); + expect(content).toContain('exportAllConversations'); + }); + + it('should have proper chrome API usage in Chrome content script', () => { + const contentFile = resolve(__dirname, '../chrome/scripts/content.ts'); + const content = readFileSync(contentFile, 'utf-8'); + + // Should use chrome.* API (Chrome compatible) + expect(content).toContain('chrome.runtime.onMessage'); + + // Should have message handlers for both export actions + expect(content).toContain('exportConversation'); + expect(content).toContain('exportAllConversations'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 9247640..496b7df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,11 +47,16 @@ "include": [ "src/**/*.ts", "vite.config.ts", + "vite.content.config.ts", + "vite.content-chrome.config.ts", + "vite.content-firefox.config.ts", "eslint.config.js" ], "exclude": [ "node_modules", "dist", - "src/**/*.js" + "src/**/*.js", + "src/chrome/scripts/**/*", + "src/firefox/scripts/**/*" ] } diff --git a/vite.config.ts b/vite.config.ts index 1bc140f..1426422 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,8 +25,11 @@ export default defineConfig(({ mode }) => { manifest, useDynamicUrlWebAccessibleResources: false, additionalInputs: { - html: ['src/browse.html'], - scripts: ['src/content.ts'], + html: [ + `src/${target}/scripts/browse.html`, + `src/${target}/scripts/options.html`, + ], + scripts: [`src/${target}/scripts/content.ts`], }, }), ], diff --git a/vite.content-chrome.config.ts b/vite.content-chrome.config.ts new file mode 100644 index 0000000..47b7ff2 --- /dev/null +++ b/vite.content-chrome.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + build: { + outDir: 'dist-temp-content-chrome', + emptyOutDir: true, + lib: { + entry: resolve(__dirname, 'src/chrome/scripts/content.ts'), + name: 'ContentScript', + formats: ['iife'], + fileName: () => 'content.js', + }, + rollupOptions: { + output: { + extend: true, + }, + }, + }, +}); diff --git a/vite.content-firefox.config.ts b/vite.content-firefox.config.ts new file mode 100644 index 0000000..84b9097 --- /dev/null +++ b/vite.content-firefox.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + build: { + outDir: 'dist-temp-content-firefox', + emptyOutDir: true, + lib: { + entry: resolve(__dirname, 'src/firefox/scripts/content.ts'), + name: 'ContentScript', + formats: ['iife'], + fileName: () => 'content.js', + }, + rollupOptions: { + output: { + extend: true, + }, + }, + }, +});