diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 284258c..a259f00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,10 @@ jobs: src/content.js icons/icon-48.png icons/icon-96.png + icons/icon-16-active.png + icons/icon-32-active.png + icons/icon-48-active.png + icons/icon-128-active.png ) MISSING=0 for f in "${REQUIRED[@]}"; do diff --git a/.gitignore b/.gitignore index 4b454c5..63ceb44 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ test-results/ # Personal test input for the PDF-importer debug toy — real chat exports, # never meant to land in the repo. debug/input/ +debug/playonwords +debug/rendertest.pdf diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eb1af40 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,166 @@ +# Changelog + +All notable changes to Inkpour are documented here, one section per released +version (matching `manifest.json`'s `version` field and the `vX.Y.Z.W` git +tag). Loosely follows [Keep a Changelog](https://keepachangelog.com/). + +This file is the single source of truth for "what's new" release-note text: +Batch 10 (automated store publishing, see `planning/TODOs.md`) will read the +section matching the tag being published and pass it as the release notes / +"recent changes" text for the Firefox (AMO) and Chrome Web Store listings. +**Keep each entry short and user-facing** — this is what an end user reviewing +an update sees, not an engineering changelog. When cutting a release, add a +new section here in the same commit as the version bump. + +## [Unreleased] + +### Added +- Notion export — send a conversation straight to a Notion page (bring your + own integration token + page ID). +- Direct-to-vault saving (Chrome/Edge) — write exports straight to a folder + on disk instead of downloading, via a one-time folder picker. +- Batch export (ChatGPT + Claude) — tick several past conversations from + the platform's own history sidebar and export them all as one ZIP. + **Pending a live end-to-end test before this is fully verified.** + +### Fixed +- ChatGPT Canvas code blocks now get a language tag on the code fence. +- Google AI Mode: fixed every turn appearing duplicated in exports. +- Footnote numbering no longer collides across messages in the same export. +- Missing `api.notion.com` permission needed for the new Notion export. + +### Changed +- Settings now autosave (no more losing changes by navigating away before + hitting Save). +- Settings page sections are now collapsible, for easier scanning. +- Context-menu export entries only appear on supported chat sites. +- Obsidian-flavored front matter now always includes a `type: ai-chat` key. +- The toolbar icon now turns green on supported AI chat sites, replacing + the old small "ON" badge — much easier to spot at a glance. +- The popup's export buttons are now a compact picker: Copy MD and ZIP stay + one click away as before, and every other format is chosen from a + dropdown, then run with a separate Export button — picking a format no + longer fires it immediately, so there's always a chance to change your + mind before a Gist/Notion upload (or any other export) actually runs. +- Renamed "Copy HTML" to "Copy" and moved it directly under the HTML + option in that dropdown, so it reads as the clipboard version of the + HTML export rather than an unrelated action. +- The export dropdown's caret is now bigger and flips direction while + open, and every option now shows a small icon (download / copy / upload) + indicating where it goes, instead of a few options spelling that out + in words. + +## [0.4.28.1] - 2026-07-17 +### Fixed +- Google AI Mode turn-duplication bug. +### Added +- Settings page autosave + sticky save bar. +### Docs +- README links to the published Firefox/Chrome store listings. + +## [0.4.28.0] - 2026-07-17 +### Fixed +- Verified and corrected several experimental extractors (Perplexity, + NotebookLM, Venice); dropped Phind (site changed); added meta.ai/arena.ai + domains. + +## [0.4.27.4] - 2026-07-16 +### Fixed +- Minor fixes from the PDF-import debug pass. + +## [0.4.27.3] - 2026-07-16 +### Fixed +- Small stability fixes. + +## [0.4.27.2] - 2026-07-16 +### Added +- Debug-mode PDF import: paste a printed/exported PDF of an AI chat back in + through the import button. + +## [0.4.27.1] - 2026-07-13 +### Fixed +- A stale pending import no longer overrides a live, currently-open + supported chat tab. + +## [0.4.27.0] - 2026-07-12 +### Added +- Clipboard-paste import — paste a copied chat transcript in and Inkpour + reconstructs the conversation. +- Gist exports now include copy/share links. +### Fixed +- An i18n locale-override bug; a History-persistence bug. + +## [0.4.26.1] - 2026-07-12 +### Fixed +- Resolved an unresolved `__MSG_x__` placeholder appearing in release + titles. + +## [0.4.26.0] - 2026-07-12 +### Added +- Full interface translation: 26 locales, with right-to-left layout support + for Arabic/Persian. + +## [0.4.25.0] - 2026-07-11 +### Fixed +- Manifest cleanup (resolved leftover merge-conflict markers). + +## [0.4.24.2] - 2026-07-11 +### Fixed +- Replaced unsafe `innerHTML` assignments with safe DOM APIs; fixed the + Perplexity, Mistral, Pi.ai, Character.AI, Venice, and lmarena extractors. + +## [0.4.24.1] - 2026-07-11 +### Fixed +- Release script housekeeping. + +## [0.4.24.0] - 2026-07-07 +### Added +- In-page scroll progress indicator during export. +- Obsidian vault path setting. +- NotebookLM inline citation numbers in exports. + +## [0.4.23.4] - 2026-07-07 +### Fixed +- Replaced an `innerHTML` assignment for the Gist URL with a safe DOM API; + hardened history's stored Gist URL handling. + +## [0.4.23.3] - 2026-07-07 +### Fixed +- Firefox AMO manifest: corrected `data_collection_permissions` to + `required: ["none"]` per spec. + +## [0.4.23.2] - 2026-07-07 +### Fixed +- Firefox AMO manifest: added required `data_collection_permissions` field. + +## [0.4.23.1] - 2026-07-07 +### Fixed +- Firefox AMO (Manifest V3) compliance: added the required + `browser_specific_settings` Gecko ID. + +## [0.4.2] - 2026-07-06 +### Added +- Four new extractors: lmarena, Character.AI, Cohere, Pi.ai. +- Safari scaffold (manual Xcode build, not store-published). +- Fuzzy search in export history; parallel multi-format export; DOCX + polish (nested lists, tables, hyperlinks); HTML export from the in-page + floating button. +### Fixed +- Tracking parameters (UTM, gclid, fbclid, msclkid, …) are now stripped + from the recorded source URL. +- Safari downloads polyfill for browsers without the native downloads API. + +## [0.2.3] - 2026-07-05 +### Added +- DOCX export, export history with search, GitHub Gist upload, webhook-on- + export, a personal notes field, reading-time estimate, lifetime export + stats. +- New extractors: z.ai, Google AI Search, Groq Playground. +- Smart export-title fallback; filename tokens (`{msgcount}`, `{words}`, + `{time}`). + +## [0.2.0] - 2026-07-05 +### Added +- Initial public release (as "Babelfish Exporter", renamed to Inkpour + shortly after). Cross-browser support (Chrome/Edge/Brave), settings page, + Markdown/PDF/HTML export of AI chat conversations. diff --git a/PRIVACY.md b/PRIVACY.md index 16dd95c..41f65c6 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -11,6 +11,8 @@ Inkpour is a browser extension that exports AI chat conversations to Markdown, P - Reads the DOM of AI chat pages you visit (ChatGPT, Claude, Gemini, Copilot, and others) when you click Export or use a keyboard shortcut. - Converts that content to your chosen format and saves it directly to your device via the browser's native download API. - Optionally uploads a Gist to GitHub if you configure a GitHub token and explicitly trigger the Upload to Gist action — the token is stored locally in your browser's extension storage and is never sent anywhere other than `api.github.com`. +- Optionally appends to a Notion page if you configure a Notion integration token + page ID and explicitly trigger the Notion export action — the token is stored locally and is never sent anywhere other than `api.notion.com`. +- Optionally writes exported files directly to a folder on your device (Chrome/Edge only) if you choose one via the "Direct-to-vault" setting — this uses the browser's File System Access API, stays entirely local, and involves no network request at all. - Optionally calls a webhook URL of your choosing if you configure one in Settings — this is entirely opt-in and you control the endpoint. ## What Inkpour does not do @@ -25,10 +27,11 @@ Inkpour is a browser extension that exports AI chat conversations to Markdown, P Inkpour stores the following data in your browser's local extension storage (`chrome.storage.local` / `browser.storage.local`): -- Your export settings (format, filename template, webhook URL, GitHub token). +- Your export settings (format, filename template, webhook URL, GitHub token, Notion token/page ID). - A local export history log (titles, timestamps, word counts) used for the in-extension history view. +- If you choose a direct-to-vault folder (Chrome/Edge only), a reference to that folder (a `FileSystemDirectoryHandle`) is kept in a separate local IndexedDB store so it doesn't need to be re-picked every time — this reference never leaves your device and grants no access beyond the one folder you explicitly chose. -This data never leaves your device unless you configure a GitHub token or webhook, in which case only the content you explicitly export is sent to those endpoints. +This data never leaves your device unless you configure a GitHub token, Notion token, or webhook, in which case only the content you explicitly export is sent to those endpoints. ## Permissions explained @@ -41,6 +44,7 @@ This data never leaves your device unless you configure a GitHub token or webhoo | `contextMenus` | Add right-click Export and Upload to Gist menu items | | Host permissions (AI chat sites) | Read conversation DOM on supported platforms | | `api.github.com` | Upload to GitHub Gist (only when you trigger it) | +| `api.notion.com` | Append to a Notion page (only when you trigger it) | ## Contact diff --git a/README.md b/README.md index 81d2bc4..8771d9f 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ Click **⏱ History** in the popup footer to see the last 20 exports. Filter by ### Integrations - **GitHub Gist** — add a Personal Access Token (gist scope) in Settings to unlock the "Gist ↑" popup button and `Alt+Shift+G` shortcut. Created Gists open in a new tab. +- **Notion** — add an integration token + target page ID in Settings to unlock the "Notion ↑" popup button. Appends the export as native Notion blocks (paragraphs, headings, code, quotes, flat lists) to the page you specify. v1 scope — nested lists and tables aren't converted yet. +- **Direct-to-vault (Chrome/Edge only)** — pick a folder once in Settings via the browser's File System Access API, then MD/DOCX/ZIP exports can write straight there instead of through Downloads. Not available in Firefox/Safari, which keep using the Downloads-subfolder option instead. - **Webhook** — POST export metadata to any URL after each export (n8n, Zapier, Make.com, custom endpoints). Toggle "Include content" to send the full exported text. ### Markdown quality @@ -209,7 +211,7 @@ Feature-rich Tampermonkey userscript covering ChatGPT, Claude, Copilot, Gemini, ## Privacy -Inkpour collects no data and makes no external requests unless you explicitly configure a GitHub token or webhook. See [PRIVACY.md](./PRIVACY.md) for full details. +Inkpour collects no data and makes no external requests unless you explicitly configure a GitHub token, Notion token, or webhook. Direct-to-vault saving (Chrome/Edge only) writes to a folder you choose and involves no network request at all. See [PRIVACY.md](./PRIVACY.md) for full details. Works in temporary/incognito chat modes too (verified on ChatGPT's Temporary Chat) — extraction is purely DOM-based, so it doesn't care whether the platform is persisting the conversation on its end. diff --git a/_locales/ar/messages.json b/_locales/ar/messages.json index c30143d..38b4776 100644 --- a/_locales/ar/messages.json +++ b/_locales/ar/messages.json @@ -114,19 +114,19 @@ "message": "نسخ MD" }, "popupBtnCopyHtml": { - "message": "نسخ HTML" + "message": "نسخ" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ تصدير الكل (MD + DOCX + ZIP)" + "message": "تصدير الكل (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "تصدير الرسائل المضافة منذ آخر تصدير فقط", @@ -610,7 +610,7 @@ "message": "رمز تكامل Notion" }, "settingsNotionTokenDesc": { - "message": "رمز تكامل داخلي — يفعّل زر \"Notion ↑\" في النافذة المنبثقة، ويُلحق الصادرات بالصفحة أدناه." + "message": "رمز تكامل داخلي — يفعّل زر \"Notion\" في النافذة المنبثقة، ويُلحق الصادرات بالصفحة أدناه." }, "settingsNotionTokenLink": { "message": "أنشئ رمزًا ←" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "الإبلاغ عن خطأ" + }, + "popupBatchExportToggle": { + "message": "📦 تصدير دفعي للمحادثات السابقة…" + }, + "popupBatchExportStartBtnTitle": { + "message": "تصدير المحادثات المحددة كملف ZIP واحد" + }, + "popupBatchExportStartBtn": { + "message": "تصدير المحدد كملف ZIP" + }, + "popupBatchExportCount": { + "message": "$1 من $2 محدد", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "حدد محادثة واحدة على الأقل أولًا" + }, + "popupBatchExportStarting": { + "message": "جارٍ تصدير $1 محادثة…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "جارٍ تصدير $1 من $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "اكتمل التصدير الدفعي: نجح $1، وتم تخطي $2", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "فشل التصدير الدفعي" + }, + "popupExportMoreOptions": { + "message": "المزيد من خيارات التصدير" + }, + "popupBtnAllShort": { + "message": "الكل" + }, + "popupExportGoBtn": { + "message": "تصدير" } } diff --git a/_locales/bn/messages.json b/_locales/bn/messages.json index 7e8a43f..9553183 100644 --- a/_locales/bn/messages.json +++ b/_locales/bn/messages.json @@ -114,19 +114,19 @@ "message": "MD কপি করুন" }, "popupBtnCopyHtml": { - "message": "HTML কপি করুন" + "message": "কপি করুন" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ সব এক্সপোর্ট করুন (MD + DOCX + ZIP)" + "message": "সব এক্সপোর্ট করুন (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "শুধু আপনার শেষ এক্সপোর্টের পর যোগ হওয়া বার্তাগুলো এক্সপোর্ট করুন", @@ -610,7 +610,7 @@ "message": "Notion ইন্টিগ্রেশন টোকেন" }, "settingsNotionTokenDesc": { - "message": "অভ্যন্তরীণ ইন্টিগ্রেশন টোকেন — পপআপে \"Notion ↑\" বাটন সক্রিয় করে, নিচের পেজে এক্সপোর্ট যুক্ত করে।" + "message": "অভ্যন্তরীণ ইন্টিগ্রেশন টোকেন — পপআপে \"Notion\" বাটন সক্রিয় করে, নিচের পেজে এক্সপোর্ট যুক্ত করে।" }, "settingsNotionTokenLink": { "message": "একটি তৈরি করুন →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "বাগ রিপোর্ট করুন" + }, + "popupBatchExportToggle": { + "message": "📦 পূর্ববর্তী কথোপকথন ব্যাচ এক্সপোর্ট করুন…" + }, + "popupBatchExportStartBtnTitle": { + "message": "টিক দেওয়া কথোপকথনগুলো একটি ZIP হিসেবে এক্সপোর্ট করুন" + }, + "popupBatchExportStartBtn": { + "message": "নির্বাচিতগুলো ZIP হিসেবে এক্সপোর্ট করুন" + }, + "popupBatchExportCount": { + "message": "$2 এর মধ্যে $1 নির্বাচিত", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "প্রথমে অন্তত একটি কথোপকথন নির্বাচন করুন" + }, + "popupBatchExportStarting": { + "message": "$1টি কথোপকথন এক্সপোর্ট করা হচ্ছে…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "$2 এর মধ্যে $1 এক্সপোর্ট করা হচ্ছে: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "ব্যাচ এক্সপোর্ট সম্পন্ন: $1টি সফল, $2টি বাদ দেওয়া হয়েছে", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "ব্যাচ এক্সপোর্ট ব্যর্থ হয়েছে" + }, + "popupExportMoreOptions": { + "message": "আরও এক্সপোর্ট বিকল্প" + }, + "popupBtnAllShort": { + "message": "সব" + }, + "popupExportGoBtn": { + "message": "এক্সপোর্ট করুন" } } diff --git a/_locales/cs/messages.json b/_locales/cs/messages.json index 9a6fb6b..720e3f4 100644 --- a/_locales/cs/messages.json +++ b/_locales/cs/messages.json @@ -91,19 +91,19 @@ "message": "Kopírovat MD" }, "popupBtnCopyHtml": { - "message": "Kopírovat HTML" + "message": "Kopírovat" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Exportovat vše (MD + DOCX + ZIP)" + "message": "Exportovat vše (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Exportovat pouze zprávy přidané od posledního exportu" @@ -585,7 +585,7 @@ "message": "Token integrace Notion" }, "settingsNotionTokenDesc": { - "message": "Interní token integrace — zpřístupní tlačítko „Notion ↑“ v okně rozšíření, které připojí exporty ke stránce níže." + "message": "Interní token integrace — zpřístupní tlačítko „Notion“ v okně rozšíření, které připojí exporty ke stránce níže." }, "settingsNotionTokenLink": { "message": "Vytvořit →" @@ -1010,5 +1010,73 @@ }, "popupBtnReportBug": { "message": "Nahlásit chybu" + }, + "popupBatchExportToggle": { + "message": "📦 Hromadný export minulých konverzací…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Exportovat zaškrtnuté konverzace jako jeden ZIP" + }, + "popupBatchExportStartBtn": { + "message": "Exportovat vybrané jako ZIP" + }, + "popupBatchExportCount": { + "message": "Vybráno $1 z $2", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Nejprve vyberte alespoň jednu konverzaci" + }, + "popupBatchExportStarting": { + "message": "Exportování $1 konverzací…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Exportování $1 z $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Hromadný export dokončen: $1 úspěšně, $2 přeskočeno", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Hromadný export se nezdařil" + }, + "popupExportMoreOptions": { + "message": "Další možnosti exportu" + }, + "popupBtnAllShort": { + "message": "Vše" + }, + "popupExportGoBtn": { + "message": "Exportovat" } } diff --git a/_locales/de/messages.json b/_locales/de/messages.json index a2b3e61..c10fb4c 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -114,19 +114,19 @@ "message": "MD kopieren" }, "popupBtnCopyHtml": { - "message": "HTML kopieren" + "message": "Kopieren" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Alle exportieren (MD + DOCX + ZIP)" + "message": "Alle exportieren (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Nur die Nachrichten seit dem letzten Export exportieren", @@ -610,7 +610,7 @@ "message": "Notion-Integrationstoken" }, "settingsNotionTokenDesc": { - "message": "Interner Integrationstoken — aktiviert die Schaltfläche „Notion ↑“ im Popup, die Exporte an die unten stehende Seite anhängt." + "message": "Interner Integrationstoken — aktiviert die Schaltfläche „Notion“ im Popup, die Exporte an die unten stehende Seite anhängt." }, "settingsNotionTokenLink": { "message": "Einen erstellen →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Fehler melden" + }, + "popupBatchExportToggle": { + "message": "📦 Vergangene Chats als Batch exportieren…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Die markierten Chats als ein ZIP exportieren" + }, + "popupBatchExportStartBtn": { + "message": "Auswahl als ZIP exportieren" + }, + "popupBatchExportCount": { + "message": "$1 von $2 ausgewählt", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Wähle zuerst mindestens einen Chat aus" + }, + "popupBatchExportStarting": { + "message": "$1 Chats werden exportiert…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Exportiere $1 von $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Batch-Export abgeschlossen: $1 erfolgreich, $2 übersprungen", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Batch-Export fehlgeschlagen" + }, + "popupExportMoreOptions": { + "message": "Weitere Exportoptionen" + }, + "popupBtnAllShort": { + "message": "Alle" + }, + "popupExportGoBtn": { + "message": "Exportieren" } } diff --git a/_locales/el/messages.json b/_locales/el/messages.json index 624f88e..4deb325 100644 --- a/_locales/el/messages.json +++ b/_locales/el/messages.json @@ -114,19 +114,19 @@ "message": "Αντιγραφή MD" }, "popupBtnCopyHtml": { - "message": "Αντιγραφή HTML" + "message": "Αντιγραφή" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Εξαγωγή όλων (MD + DOCX + ZIP)" + "message": "Εξαγωγή όλων (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Εξαγωγή μόνο των μηνυμάτων που προστέθηκαν από την τελευταία εξαγωγή", @@ -610,7 +610,7 @@ "message": "Token ενσωμάτωσης Notion" }, "settingsNotionTokenDesc": { - "message": "Εσωτερικό token ενσωμάτωσης — ενεργοποιεί το κουμπί «Notion ↑» στο popup, προσαρτώντας τις εξαγωγές στην παρακάτω σελίδα." + "message": "Εσωτερικό token ενσωμάτωσης — ενεργοποιεί το κουμπί «Notion» στο popup, προσαρτώντας τις εξαγωγές στην παρακάτω σελίδα." }, "settingsNotionTokenLink": { "message": "Δημιουργήστε ένα →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Αναφορά σφάλματος" + }, + "popupBatchExportToggle": { + "message": "📦 Μαζική εξαγωγή παλαιότερων συνομιλιών…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Εξαγωγή των επιλεγμένων συνομιλιών ως ένα ZIP" + }, + "popupBatchExportStartBtn": { + "message": "Εξαγωγή επιλεγμένων ως ZIP" + }, + "popupBatchExportCount": { + "message": "$1 από $2 επιλεγμένα", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Επιλέξτε πρώτα τουλάχιστον μία συνομιλία" + }, + "popupBatchExportStarting": { + "message": "Εξαγωγή $1 συνομιλιών…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Εξαγωγή $1 από $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Η μαζική εξαγωγή ολοκληρώθηκε: $1 επιτυχείς, $2 παραλείφθηκαν", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Η μαζική εξαγωγή απέτυχε" + }, + "popupExportMoreOptions": { + "message": "Περισσότερες επιλογές εξαγωγής" + }, + "popupBtnAllShort": { + "message": "Όλα" + }, + "popupExportGoBtn": { + "message": "Εξαγωγή" } } diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a5b4d8f..63368bb 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -160,19 +160,19 @@ "message": "Copy MD" }, "popupBtnCopyHtml": { - "message": "Copy HTML" + "message": "Copy" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Export All (MD + DOCX + ZIP)" + "message": "Export All (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Export only the messages added since your last export", @@ -690,7 +690,7 @@ "message": "Notion integration token" }, "settingsNotionTokenDesc": { - "message": "Internal integration token — enables the \"Notion ↑\" button in popup, appending exports to the page below." + "message": "Internal integration token — enables the \"Notion\" button in popup, appending exports to the page below." }, "settingsNotionTokenLink": { "message": "Create one →" @@ -961,5 +961,73 @@ "content": "$2" } } + }, + "popupBatchExportToggle": { + "message": "📦 Batch export past conversations…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Export the ticked conversations as one ZIP" + }, + "popupBatchExportStartBtn": { + "message": "Export selected as ZIP" + }, + "popupBatchExportCount": { + "message": "$1 of $2 selected", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Select at least one conversation first" + }, + "popupBatchExportStarting": { + "message": "Exporting $1 conversations…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Exporting $1 of $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Batch export done: $1 succeeded, $2 skipped", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Batch export failed" + }, + "popupExportMoreOptions": { + "message": "More export options" + }, + "popupBtnAllShort": { + "message": "All" + }, + "popupExportGoBtn": { + "message": "Export" } } diff --git a/_locales/es/messages.json b/_locales/es/messages.json index 53d4848..e0e37e0 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -114,19 +114,19 @@ "message": "Copiar MD" }, "popupBtnCopyHtml": { - "message": "Copiar HTML" + "message": "Copiar" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Exportar todo (MD + DOCX + ZIP)" + "message": "Exportar todo (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Exportar solo los mensajes añadidos desde tu última exportación", @@ -610,7 +610,7 @@ "message": "Token de integración de Notion" }, "settingsNotionTokenDesc": { - "message": "Token de integración interna — habilita el botón \"Notion ↑\" en el popup, que añade las exportaciones a la página indicada abajo." + "message": "Token de integración interna — habilita el botón \"Notion\" en el popup, que añade las exportaciones a la página indicada abajo." }, "settingsNotionTokenLink": { "message": "Crear uno →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Informar de un error" + }, + "popupBatchExportToggle": { + "message": "📦 Exportar conversaciones anteriores por lotes…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Exportar las conversaciones marcadas como un solo ZIP" + }, + "popupBatchExportStartBtn": { + "message": "Exportar seleccionadas como ZIP" + }, + "popupBatchExportCount": { + "message": "$1 de $2 seleccionados", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Selecciona al menos una conversación primero" + }, + "popupBatchExportStarting": { + "message": "Exportando $1 conversaciones…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Exportando $1 de $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Exportación por lotes finalizada: $1 correctas, $2 omitidas", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Error en la exportación por lotes" + }, + "popupExportMoreOptions": { + "message": "Más opciones de exportación" + }, + "popupBtnAllShort": { + "message": "Todo" + }, + "popupExportGoBtn": { + "message": "Exportar" } } diff --git a/_locales/fa/messages.json b/_locales/fa/messages.json index e30767b..675ad88 100644 --- a/_locales/fa/messages.json +++ b/_locales/fa/messages.json @@ -114,19 +114,19 @@ "message": "کپی MD" }, "popupBtnCopyHtml": { - "message": "کپی HTML" + "message": "کپی" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ خروجی همه (MD + DOCX + ZIP)" + "message": "خروجی همه (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "فقط پیام‌های افزوده‌شده از آخرین خروجی گرفتن را خروجی می‌گیرد", @@ -610,7 +610,7 @@ "message": "توکن یکپارچه‌سازی Notion" }, "settingsNotionTokenDesc": { - "message": "توکن یکپارچه‌سازی داخلی — دکمه «Notion ↑» را در پاپ‌آپ فعال می‌کند و خروجی‌ها را به صفحه زیر می‌افزاید." + "message": "توکن یکپارچه‌سازی داخلی — دکمه «Notion» را در پاپ‌آپ فعال می‌کند و خروجی‌ها را به صفحه زیر می‌افزاید." }, "settingsNotionTokenLink": { "message": "ساخت یک توکن →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "گزارش اشکال" + }, + "popupBatchExportToggle": { + "message": "📦 خروجی گرفتن دسته‌ای از گفتگوهای گذشته…" + }, + "popupBatchExportStartBtnTitle": { + "message": "خروجی گرفتن از گفتگوهای تیک‌خورده به‌صورت یک ZIP" + }, + "popupBatchExportStartBtn": { + "message": "خروجی گرفتن از انتخاب‌شده‌ها به‌صورت ZIP" + }, + "popupBatchExportCount": { + "message": "$1 از $2 انتخاب شد", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "ابتدا حداقل یک گفتگو را انتخاب کنید" + }, + "popupBatchExportStarting": { + "message": "در حال خروجی گرفتن از $1 گفتگو…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "در حال خروجی گرفتن از $1 از $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "خروجی‌گیری دسته‌ای به پایان رسید: $1 موفق، $2 نادیده گرفته شد", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "خروجی‌گیری دسته‌ای ناموفق بود" + }, + "popupExportMoreOptions": { + "message": "گزینه‌های بیشتر خروجی" + }, + "popupBtnAllShort": { + "message": "همه" + }, + "popupExportGoBtn": { + "message": "خروجی گرفتن" } } diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 7a3125d..9846e9f 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -114,19 +114,19 @@ "message": "Copier MD" }, "popupBtnCopyHtml": { - "message": "Copier HTML" + "message": "Copier" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Tout exporter (MD + DOCX + ZIP)" + "message": "Tout exporter (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Exporter uniquement les messages ajoutés depuis votre dernier export", @@ -610,7 +610,7 @@ "message": "Jeton d'intégration Notion" }, "settingsNotionTokenDesc": { - "message": "Jeton d'intégration interne — active le bouton « Notion ↑ » dans le popup, qui ajoute les exports à la page ci-dessous." + "message": "Jeton d'intégration interne — active le bouton « Notion » dans le popup, qui ajoute les exports à la page ci-dessous." }, "settingsNotionTokenLink": { "message": "En créer un →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Signaler un bug" + }, + "popupBatchExportToggle": { + "message": "📦 Exporter en lot les conversations précédentes…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Exporter les conversations cochées en un seul ZIP" + }, + "popupBatchExportStartBtn": { + "message": "Exporter la sélection en ZIP" + }, + "popupBatchExportCount": { + "message": "$1 sur $2 sélectionné(s)", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Sélectionnez d'abord au moins une conversation" + }, + "popupBatchExportStarting": { + "message": "Export de $1 conversations…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Export de $1 sur $2 : $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Export en lot terminé : $1 réussi(s), $2 ignoré(s)", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Échec de l'export en lot" + }, + "popupExportMoreOptions": { + "message": "Plus d'options d'export" + }, + "popupBtnAllShort": { + "message": "Tout" + }, + "popupExportGoBtn": { + "message": "Exporter" } } diff --git a/_locales/hi/messages.json b/_locales/hi/messages.json index 2d10486..56998e4 100644 --- a/_locales/hi/messages.json +++ b/_locales/hi/messages.json @@ -114,19 +114,19 @@ "message": "MD कॉपी करें" }, "popupBtnCopyHtml": { - "message": "HTML कॉपी करें" + "message": "कॉपी करें" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ सभी एक्सपोर्ट करें (MD + DOCX + ZIP)" + "message": "सभी एक्सपोर्ट करें (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "आपके पिछले एक्सपोर्ट के बाद जोड़े गए संदेशों को ही एक्सपोर्ट करें", @@ -610,7 +610,7 @@ "message": "Notion इंटीग्रेशन टोकन" }, "settingsNotionTokenDesc": { - "message": "इंटरनल इंटीग्रेशन टोकन — पॉपअप में \"Notion ↑\" बटन सक्षम करता है, जो एक्सपोर्ट को नीचे दिए गए पेज में जोड़ता है।" + "message": "इंटरनल इंटीग्रेशन टोकन — पॉपअप में \"Notion\" बटन सक्षम करता है, जो एक्सपोर्ट को नीचे दिए गए पेज में जोड़ता है।" }, "settingsNotionTokenLink": { "message": "एक बनाएं →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "बग रिपोर्ट करें" + }, + "popupBatchExportToggle": { + "message": "📦 पिछली बातचीत को बैच में एक्सपोर्ट करें…" + }, + "popupBatchExportStartBtnTitle": { + "message": "चुनी गई बातचीत को एक ZIP के रूप में एक्सपोर्ट करें" + }, + "popupBatchExportStartBtn": { + "message": "चयनित को ZIP के रूप में एक्सपोर्ट करें" + }, + "popupBatchExportCount": { + "message": "$2 में से $1 चयनित", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "पहले कम से कम एक बातचीत चुनें" + }, + "popupBatchExportStarting": { + "message": "$1 बातचीत एक्सपोर्ट की जा रही हैं…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "$2 में से $1 एक्सपोर्ट हो रहा है: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "बैच एक्सपोर्ट पूर्ण: $1 सफल, $2 छोड़े गए", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "बैच एक्सपोर्ट विफल रहा" + }, + "popupExportMoreOptions": { + "message": "अधिक एक्सपोर्ट विकल्प" + }, + "popupBtnAllShort": { + "message": "सभी" + }, + "popupExportGoBtn": { + "message": "एक्सपोर्ट करें" } } diff --git a/_locales/id/messages.json b/_locales/id/messages.json index bdcdc93..3701f72 100644 --- a/_locales/id/messages.json +++ b/_locales/id/messages.json @@ -114,19 +114,19 @@ "message": "Salin MD" }, "popupBtnCopyHtml": { - "message": "Salin HTML" + "message": "Salin" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Ekspor Semua (MD + DOCX + ZIP)" + "message": "Ekspor Semua (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Ekspor hanya pesan yang ditambahkan sejak ekspor terakhir Anda", @@ -610,7 +610,7 @@ "message": "Token integrasi Notion" }, "settingsNotionTokenDesc": { - "message": "Token integrasi internal — mengaktifkan tombol \"Notion ↑\" di popup, menambahkan ekspor ke halaman di bawah." + "message": "Token integrasi internal — mengaktifkan tombol \"Notion\" di popup, menambahkan ekspor ke halaman di bawah." }, "settingsNotionTokenLink": { "message": "Buat satu →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Laporkan bug" + }, + "popupBatchExportToggle": { + "message": "📦 Ekspor massal percakapan sebelumnya…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Ekspor percakapan yang dicentang sebagai satu ZIP" + }, + "popupBatchExportStartBtn": { + "message": "Ekspor yang dipilih sebagai ZIP" + }, + "popupBatchExportCount": { + "message": "$1 dari $2 dipilih", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Pilih setidaknya satu percakapan terlebih dahulu" + }, + "popupBatchExportStarting": { + "message": "Mengekspor $1 percakapan…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Mengekspor $1 dari $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Ekspor massal selesai: $1 berhasil, $2 dilewati", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Ekspor massal gagal" + }, + "popupExportMoreOptions": { + "message": "Opsi ekspor lainnya" + }, + "popupBtnAllShort": { + "message": "Semua" + }, + "popupExportGoBtn": { + "message": "Ekspor" } } diff --git a/_locales/it/messages.json b/_locales/it/messages.json index b11b5be..aa2863c 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -114,19 +114,19 @@ "message": "Copia MD" }, "popupBtnCopyHtml": { - "message": "Copia HTML" + "message": "Copia" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Esporta tutto (MD + DOCX + ZIP)" + "message": "Esporta tutto (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Esporta solo i messaggi aggiunti dopo l'ultima esportazione", @@ -610,7 +610,7 @@ "message": "Token di integrazione Notion" }, "settingsNotionTokenDesc": { - "message": "Token di integrazione interna — abilita il pulsante \"Notion ↑\" nel popup, che aggiunge le esportazioni alla pagina sottostante." + "message": "Token di integrazione interna — abilita il pulsante \"Notion\" nel popup, che aggiunge le esportazioni alla pagina sottostante." }, "settingsNotionTokenLink": { "message": "Creane uno →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Segnala un bug" + }, + "popupBatchExportToggle": { + "message": "📦 Esporta in blocco le conversazioni precedenti…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Esporta le conversazioni selezionate come un unico ZIP" + }, + "popupBatchExportStartBtn": { + "message": "Esporta la selezione come ZIP" + }, + "popupBatchExportCount": { + "message": "$1 di $2 selezionate", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Seleziona prima almeno una conversazione" + }, + "popupBatchExportStarting": { + "message": "Esportazione di $1 conversazioni…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Esportazione di $1 di $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Esportazione in blocco completata: $1 riuscite, $2 saltate", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Esportazione in blocco non riuscita" + }, + "popupExportMoreOptions": { + "message": "Altre opzioni di esportazione" + }, + "popupBtnAllShort": { + "message": "Tutto" + }, + "popupExportGoBtn": { + "message": "Esporta" } } diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index 7083598..93e901e 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -114,19 +114,19 @@ "message": "MDをコピー" }, "popupBtnCopyHtml": { - "message": "HTMLをコピー" + "message": "コピー" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ 全てエクスポート(MD + DOCX + ZIP)" + "message": "全てエクスポート(MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "前回のエクスポート以降に追加されたメッセージのみをエクスポート", @@ -610,7 +610,7 @@ "message": "Notion統合トークン" }, "settingsNotionTokenDesc": { - "message": "内部統合トークン — ポップアップの「Notion ↑」ボタンが有効になり、下記のページにエクスポートを追加します。" + "message": "内部統合トークン — ポップアップの「Notion」ボタンが有効になり、下記のページにエクスポートを追加します。" }, "settingsNotionTokenLink": { "message": "作成する →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "バグを報告" + }, + "popupBatchExportToggle": { + "message": "📦 過去のチャットを一括エクスポート…" + }, + "popupBatchExportStartBtnTitle": { + "message": "チェックしたチャットを1つのZIPとしてエクスポート" + }, + "popupBatchExportStartBtn": { + "message": "選択項目をZIPでエクスポート" + }, + "popupBatchExportCount": { + "message": "$2件中$1件選択", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "まず少なくとも1件のチャットを選択してください" + }, + "popupBatchExportStarting": { + "message": "$1件のチャットをエクスポート中…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "$2件中$1件をエクスポート中: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "一括エクスポート完了: 成功$1件、スキップ$2件", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "一括エクスポートに失敗しました" + }, + "popupExportMoreOptions": { + "message": "他のエクスポートオプション" + }, + "popupBtnAllShort": { + "message": "全て" + }, + "popupExportGoBtn": { + "message": "エクスポート" } } diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index 1ea8097..37c6b88 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -114,19 +114,19 @@ "message": "MD 복사" }, "popupBtnCopyHtml": { - "message": "HTML 복사" + "message": "복사" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ 모두 내보내기 (MD + DOCX + ZIP)" + "message": "모두 내보내기 (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "마지막 내보내기 이후 추가된 메시지만 내보냅니다", @@ -610,7 +610,7 @@ "message": "Notion 통합 토큰" }, "settingsNotionTokenDesc": { - "message": "내부 통합 토큰 — 팝업에서 \"Notion ↑\" 버튼을 활성화하여 아래 페이지에 내보내기를 추가합니다." + "message": "내부 통합 토큰 — 팝업에서 \"Notion\" 버튼을 활성화하여 아래 페이지에 내보내기를 추가합니다." }, "settingsNotionTokenLink": { "message": "토큰 만들기 →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "버그 신고" + }, + "popupBatchExportToggle": { + "message": "📦 이전 채팅 일괄 내보내기…" + }, + "popupBatchExportStartBtnTitle": { + "message": "선택한 채팅을 하나의 ZIP으로 내보내기" + }, + "popupBatchExportStartBtn": { + "message": "선택 항목을 ZIP으로 내보내기" + }, + "popupBatchExportCount": { + "message": "$2개 중 $1개 선택됨", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "먼저 채팅을 하나 이상 선택하세요" + }, + "popupBatchExportStarting": { + "message": "채팅 $1개를 내보내는 중…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "$2개 중 $1개 내보내는 중: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "일괄 내보내기 완료: $1개 성공, $2개 건너뜀", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "일괄 내보내기 실패" + }, + "popupExportMoreOptions": { + "message": "더 많은 내보내기 옵션" + }, + "popupBtnAllShort": { + "message": "모두" + }, + "popupExportGoBtn": { + "message": "내보내기" } } diff --git a/_locales/nl/messages.json b/_locales/nl/messages.json index fa317d8..fc0a0f5 100644 --- a/_locales/nl/messages.json +++ b/_locales/nl/messages.json @@ -114,19 +114,19 @@ "message": "MD kopiëren" }, "popupBtnCopyHtml": { - "message": "HTML kopiëren" + "message": "Kopiëren" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Alles exporteren (MD + DOCX + ZIP)" + "message": "Alles exporteren (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Exporteer alleen de berichten die zijn toegevoegd sinds je laatste export", @@ -610,7 +610,7 @@ "message": "Notion-integratietoken" }, "settingsNotionTokenDesc": { - "message": "Interne integratietoken — schakelt de knop \"Notion ↑\" in de popup in, die exports toevoegt aan de onderstaande pagina." + "message": "Interne integratietoken — schakelt de knop \"Notion\" in de popup in, die exports toevoegt aan de onderstaande pagina." }, "settingsNotionTokenLink": { "message": "Maak er een aan →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Bug melden" + }, + "popupBatchExportToggle": { + "message": "📦 Eerdere gesprekken in bulk exporteren…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Exporteer de aangevinkte gesprekken als één ZIP" + }, + "popupBatchExportStartBtn": { + "message": "Geselecteerde exporteren als ZIP" + }, + "popupBatchExportCount": { + "message": "$1 van $2 geselecteerd", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Selecteer eerst minstens één gesprek" + }, + "popupBatchExportStarting": { + "message": "Bezig met exporteren van $1 gesprekken…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Bezig met exporteren van $1 van $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Bulkexport voltooid: $1 geslaagd, $2 overgeslagen", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Bulkexport mislukt" + }, + "popupExportMoreOptions": { + "message": "Meer exportopties" + }, + "popupBtnAllShort": { + "message": "Alles" + }, + "popupExportGoBtn": { + "message": "Exporteren" } } diff --git a/_locales/pa/messages.json b/_locales/pa/messages.json index 60b9cd4..dd7592c 100644 --- a/_locales/pa/messages.json +++ b/_locales/pa/messages.json @@ -91,19 +91,19 @@ "message": "MD ਕਾਪੀ ਕਰੋ" }, "popupBtnCopyHtml": { - "message": "HTML ਕਾਪੀ ਕਰੋ" + "message": "ਕਾਪੀ ਕਰੋ" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ ਸਭ ਐਕਸਪੋਰਟ ਕਰੋ (MD + DOCX + ZIP)" + "message": "ਸਭ ਐਕਸਪੋਰਟ ਕਰੋ (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "ਸਿਰਫ਼ ਪਿਛਲੀ ਐਕਸਪੋਰਟ ਤੋਂ ਬਾਅਦ ਜੋੜੇ ਗਏ ਸੁਨੇਹੇ ਐਕਸਪੋਰਟ ਕਰੋ" @@ -585,7 +585,7 @@ "message": "Notion ਏਕੀਕਰਨ ਟੋਕਨ" }, "settingsNotionTokenDesc": { - "message": "ਅੰਦਰੂਨੀ ਏਕੀਕਰਨ ਟੋਕਨ — ਪੌਪਅੱਪ ਵਿੱਚ \"Notion ↑\" ਬਟਨ ਚਾਲੂ ਕਰਦਾ ਹੈ, ਜੋ ਐਕਸਪੋਰਟ ਨੂੰ ਹੇਠਾਂ ਦਿੱਤੇ ਪੇਜ ਵਿੱਚ ਜੋੜਦਾ ਹੈ।" + "message": "ਅੰਦਰੂਨੀ ਏਕੀਕਰਨ ਟੋਕਨ — ਪੌਪਅੱਪ ਵਿੱਚ \"Notion\" ਬਟਨ ਚਾਲੂ ਕਰਦਾ ਹੈ, ਜੋ ਐਕਸਪੋਰਟ ਨੂੰ ਹੇਠਾਂ ਦਿੱਤੇ ਪੇਜ ਵਿੱਚ ਜੋੜਦਾ ਹੈ।" }, "settingsNotionTokenLink": { "message": "ਇੱਕ ਬਣਾਓ →" @@ -1010,5 +1010,73 @@ }, "popupBtnReportBug": { "message": "ਬੱਗ ਰਿਪੋਰਟ ਕਰੋ" + }, + "popupBatchExportToggle": { + "message": "📦 ਪਿਛਲੀਆਂ ਗੱਲਬਾਤਾਂ ਬੈਚ ਐਕਸਪੋਰਟ ਕਰੋ…" + }, + "popupBatchExportStartBtnTitle": { + "message": "ਨਿਸ਼ਾਨਬੱਧ ਗੱਲਬਾਤਾਂ ਨੂੰ ਇੱਕ ZIP ਵਜੋਂ ਐਕਸਪੋਰਟ ਕਰੋ" + }, + "popupBatchExportStartBtn": { + "message": "ਚੁਣੀਆਂ ਨੂੰ ZIP ਵਜੋਂ ਐਕਸਪੋਰਟ ਕਰੋ" + }, + "popupBatchExportCount": { + "message": "$2 ਵਿੱਚੋਂ $1 ਚੁਣੀਆਂ ਗਈਆਂ", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "ਪਹਿਲਾਂ ਘੱਟੋ-ਘੱਟ ਇੱਕ ਗੱਲਬਾਤ ਚੁਣੋ" + }, + "popupBatchExportStarting": { + "message": "$1 ਗੱਲਬਾਤਾਂ ਐਕਸਪੋਰਟ ਹੋ ਰਹੀਆਂ ਹਨ…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "$2 ਵਿੱਚੋਂ $1 ਐਕਸਪੋਰਟ ਹੋ ਰਿਹਾ ਹੈ: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "ਬੈਚ ਐਕਸਪੋਰਟ ਪੂਰਾ ਹੋਇਆ: $1 ਸਫਲ, $2 ਛੱਡੀਆਂ ਗਈਆਂ", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "ਬੈਚ ਐਕਸਪੋਰਟ ਅਸਫਲ ਹੋਇਆ" + }, + "popupExportMoreOptions": { + "message": "ਹੋਰ ਐਕਸਪੋਰਟ ਵਿਕਲਪ" + }, + "popupBtnAllShort": { + "message": "ਸਭ" + }, + "popupExportGoBtn": { + "message": "ਐਕਸਪੋਰਟ ਕਰੋ" } } diff --git a/_locales/pl/messages.json b/_locales/pl/messages.json index dc041af..dcb0c93 100644 --- a/_locales/pl/messages.json +++ b/_locales/pl/messages.json @@ -114,19 +114,19 @@ "message": "Kopiuj MD" }, "popupBtnCopyHtml": { - "message": "Kopiuj HTML" + "message": "Kopiuj" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Eksportuj wszystko (MD + DOCX + ZIP)" + "message": "Eksportuj wszystko (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Eksportuj tylko wiadomości dodane od ostatniego eksportu", @@ -610,7 +610,7 @@ "message": "Token integracji Notion" }, "settingsNotionTokenDesc": { - "message": "Wewnętrzny token integracji — włącza przycisk „Notion ↑” w wyskakującym oknie, który dołącza eksporty do poniższej strony." + "message": "Wewnętrzny token integracji — włącza przycisk „Notion” w wyskakującym oknie, który dołącza eksporty do poniższej strony." }, "settingsNotionTokenLink": { "message": "Utwórz →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Zgłoś błąd" + }, + "popupBatchExportToggle": { + "message": "📦 Eksportuj masowo poprzednie rozmowy…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Eksportuj zaznaczone rozmowy jako jeden plik ZIP" + }, + "popupBatchExportStartBtn": { + "message": "Eksportuj zaznaczone jako ZIP" + }, + "popupBatchExportCount": { + "message": "Wybrano $1 z $2", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Najpierw zaznacz co najmniej jedną rozmowę" + }, + "popupBatchExportStarting": { + "message": "Eksportowanie $1 rozmów…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Eksportowanie $1 z $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Eksport zbiorczy zakończony: $1 powiodło się, $2 pominięto", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Eksport zbiorczy nie powiódł się" + }, + "popupExportMoreOptions": { + "message": "Więcej opcji eksportu" + }, + "popupBtnAllShort": { + "message": "Wszystko" + }, + "popupExportGoBtn": { + "message": "Eksportuj" } } diff --git a/_locales/pt_BR/messages.json b/_locales/pt_BR/messages.json index c7bb17a..e009735 100644 --- a/_locales/pt_BR/messages.json +++ b/_locales/pt_BR/messages.json @@ -114,19 +114,19 @@ "message": "Copiar MD" }, "popupBtnCopyHtml": { - "message": "Copiar HTML" + "message": "Copiar" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Exportar tudo (MD + DOCX + ZIP)" + "message": "Exportar tudo (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Exportar somente as mensagens adicionadas desde sua última exportação", @@ -610,7 +610,7 @@ "message": "Token de integração do Notion" }, "settingsNotionTokenDesc": { - "message": "Token de integração interna — habilita o botão \"Notion ↑\" no popup, que anexa as exportações à página abaixo." + "message": "Token de integração interna — habilita o botão \"Notion\" no popup, que anexa as exportações à página abaixo." }, "settingsNotionTokenLink": { "message": "Criar um →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Reportar bug" + }, + "popupBatchExportToggle": { + "message": "📦 Exportar em lote conversas anteriores…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Exportar as conversas marcadas como um único ZIP" + }, + "popupBatchExportStartBtn": { + "message": "Exportar selecionadas como ZIP" + }, + "popupBatchExportCount": { + "message": "$1 de $2 selecionados", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Selecione pelo menos uma conversa primeiro" + }, + "popupBatchExportStarting": { + "message": "Exportando $1 conversas…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Exportando $1 de $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Exportação em lote concluída: $1 bem-sucedidas, $2 ignoradas", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Falha na exportação em lote" + }, + "popupExportMoreOptions": { + "message": "Mais opções de exportação" + }, + "popupBtnAllShort": { + "message": "Tudo" + }, + "popupExportGoBtn": { + "message": "Exportar" } } diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index 0069e7d..3695f37 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -114,19 +114,19 @@ "message": "Копировать MD" }, "popupBtnCopyHtml": { - "message": "Копировать HTML" + "message": "Скопировать" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Экспортировать всё (MD + DOCX + ZIP)" + "message": "Экспортировать всё (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Экспортировать только сообщения, добавленные после последнего экспорта", @@ -610,7 +610,7 @@ "message": "Токен интеграции Notion" }, "settingsNotionTokenDesc": { - "message": "Внутренний токен интеграции — включает кнопку «Notion ↑» во всплывающем окне, добавляющую экспорт на указанную ниже страницу." + "message": "Внутренний токен интеграции — включает кнопку «Notion» во всплывающем окне, добавляющую экспорт на указанную ниже страницу." }, "settingsNotionTokenLink": { "message": "Создать →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Сообщить об ошибке" + }, + "popupBatchExportToggle": { + "message": "📦 Пакетный экспорт прошлых переписок…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Экспортировать отмеченные переписки в один ZIP" + }, + "popupBatchExportStartBtn": { + "message": "Экспортировать выбранные как ZIP" + }, + "popupBatchExportCount": { + "message": "Выбрано $1 из $2", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Сначала выберите хотя бы одну переписку" + }, + "popupBatchExportStarting": { + "message": "Экспорт $1 переписок…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Экспорт $1 из $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Пакетный экспорт завершён: успешно $1, пропущено $2", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Не удалось выполнить пакетный экспорт" + }, + "popupExportMoreOptions": { + "message": "Дополнительные параметры экспорта" + }, + "popupBtnAllShort": { + "message": "Всё" + }, + "popupExportGoBtn": { + "message": "Экспортировать" } } diff --git a/_locales/sv/messages.json b/_locales/sv/messages.json index ebc23e0..a2cbfdf 100644 --- a/_locales/sv/messages.json +++ b/_locales/sv/messages.json @@ -114,19 +114,19 @@ "message": "Kopiera MD" }, "popupBtnCopyHtml": { - "message": "Kopiera HTML" + "message": "Kopiera" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Exportera allt (MD + DOCX + ZIP)" + "message": "Exportera allt (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Exportera endast meddelanden som tillkommit sedan din senaste export", @@ -610,7 +610,7 @@ "message": "Notion-integrationstoken" }, "settingsNotionTokenDesc": { - "message": "Intern integrationstoken — aktiverar knappen \"Notion ↑\" i popupen, som lägger till exporter på sidan nedan." + "message": "Intern integrationstoken — aktiverar knappen \"Notion\" i popupen, som lägger till exporter på sidan nedan." }, "settingsNotionTokenLink": { "message": "Skapa en →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Rapportera bugg" + }, + "popupBatchExportToggle": { + "message": "📦 Massexportera tidigare konversationer…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Exportera de markerade konversationerna som en ZIP" + }, + "popupBatchExportStartBtn": { + "message": "Exportera valda som ZIP" + }, + "popupBatchExportCount": { + "message": "$1 av $2 markerade", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Välj minst en konversation först" + }, + "popupBatchExportStarting": { + "message": "Exporterar $1 konversationer…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Exporterar $1 av $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Massexport klar: $1 lyckades, $2 hoppades över", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Massexport misslyckades" + }, + "popupExportMoreOptions": { + "message": "Fler exportalternativ" + }, + "popupBtnAllShort": { + "message": "Allt" + }, + "popupExportGoBtn": { + "message": "Exportera" } } diff --git a/_locales/th/messages.json b/_locales/th/messages.json index 94abd2c..f50b9a7 100644 --- a/_locales/th/messages.json +++ b/_locales/th/messages.json @@ -114,19 +114,19 @@ "message": "คัดลอก MD" }, "popupBtnCopyHtml": { - "message": "คัดลอก HTML" + "message": "คัดลอก" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ ส่งออกทั้งหมด (MD + DOCX + ZIP)" + "message": "ส่งออกทั้งหมด (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "ส่งออกเฉพาะข้อความที่เพิ่มขึ้นตั้งแต่การส่งออกครั้งล่าสุด", @@ -610,7 +610,7 @@ "message": "โทเคนการเชื่อมต่อ Notion" }, "settingsNotionTokenDesc": { - "message": "โทเคนการเชื่อมต่อภายใน — เปิดใช้งานปุ่ม \"Notion ↑\" ในหน้าต่างป๊อปอัป ซึ่งจะเพิ่มการส่งออกไปยังหน้าด้านล่าง" + "message": "โทเคนการเชื่อมต่อภายใน — เปิดใช้งานปุ่ม \"Notion\" ในหน้าต่างป๊อปอัป ซึ่งจะเพิ่มการส่งออกไปยังหน้าด้านล่าง" }, "settingsNotionTokenLink": { "message": "สร้างโทเคน →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "รายงานข้อบกพร่อง" + }, + "popupBatchExportToggle": { + "message": "📦 ส่งออกบทสนทนาที่ผ่านมาเป็นชุด…" + }, + "popupBatchExportStartBtnTitle": { + "message": "ส่งออกบทสนทนาที่ทำเครื่องหมายไว้เป็น ZIP ไฟล์เดียว" + }, + "popupBatchExportStartBtn": { + "message": "ส่งออกที่เลือกเป็น ZIP" + }, + "popupBatchExportCount": { + "message": "เลือกแล้ว $1 จาก $2", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "กรุณาเลือกบทสนทนาอย่างน้อยหนึ่งรายการก่อน" + }, + "popupBatchExportStarting": { + "message": "กำลังส่งออกบทสนทนา $1 รายการ…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "กำลังส่งออก $1 จาก $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "ส่งออกเป็นชุดเสร็จสิ้น: สำเร็จ $1 รายการ ข้ามไป $2 รายการ", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "ส่งออกเป็นชุดไม่สำเร็จ" + }, + "popupExportMoreOptions": { + "message": "ตัวเลือกการส่งออกเพิ่มเติม" + }, + "popupBtnAllShort": { + "message": "ทั้งหมด" + }, + "popupExportGoBtn": { + "message": "ส่งออก" } } diff --git a/_locales/tr/messages.json b/_locales/tr/messages.json index e105cb3..28d6c1c 100644 --- a/_locales/tr/messages.json +++ b/_locales/tr/messages.json @@ -114,19 +114,19 @@ "message": "MD Kopyala" }, "popupBtnCopyHtml": { - "message": "HTML Kopyala" + "message": "Kopyala" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Tümünü Dışa Aktar (MD + DOCX + ZIP)" + "message": "Tümünü Dışa Aktar (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Yalnızca son dışa aktarımdan sonra eklenen mesajları dışa aktarır", @@ -610,7 +610,7 @@ "message": "Notion entegrasyon token'ı" }, "settingsNotionTokenDesc": { - "message": "Dahili entegrasyon token'ı — popup'ta \"Notion ↑\" düğmesini etkinleştirir ve dışa aktarımları aşağıdaki sayfaya ekler." + "message": "Dahili entegrasyon token'ı — popup'ta \"Notion\" düğmesini etkinleştirir ve dışa aktarımları aşağıdaki sayfaya ekler." }, "settingsNotionTokenLink": { "message": "Bir tane oluştur →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Hata bildir" + }, + "popupBatchExportToggle": { + "message": "📦 Geçmiş sohbetleri toplu dışa aktar…" + }, + "popupBatchExportStartBtnTitle": { + "message": "İşaretlenen sohbetleri tek bir ZIP olarak dışa aktarır" + }, + "popupBatchExportStartBtn": { + "message": "Seçilenleri ZIP olarak dışa aktar" + }, + "popupBatchExportCount": { + "message": "$2 içinde $1 seçildi", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Önce en az bir sohbet seçin." + }, + "popupBatchExportStarting": { + "message": "$1 sohbet dışa aktarılıyor…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "$2 içinden $1 dışa aktarılıyor: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Toplu dışa aktarma tamamlandı: $1 başarılı, $2 atlandı", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Toplu dışa aktarma başarısız oldu" + }, + "popupExportMoreOptions": { + "message": "Diğer dışa aktarma seçenekleri" + }, + "popupBtnAllShort": { + "message": "Tümü" + }, + "popupExportGoBtn": { + "message": "Dışa Aktar" } } diff --git a/_locales/uk/messages.json b/_locales/uk/messages.json index 2257ade..b6dea0d 100644 --- a/_locales/uk/messages.json +++ b/_locales/uk/messages.json @@ -114,19 +114,19 @@ "message": "Копіювати MD" }, "popupBtnCopyHtml": { - "message": "Копіювати HTML" + "message": "Скопіювати" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Експортувати все (MD + DOCX + ZIP)" + "message": "Експортувати все (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Експортувати лише повідомлення, додані після останнього експорту", @@ -610,7 +610,7 @@ "message": "Токен інтеграції Notion" }, "settingsNotionTokenDesc": { - "message": "Внутрішній токен інтеграції — вмикає кнопку «Notion ↑» у спливному вікні, яка додає експорт на вказану нижче сторінку." + "message": "Внутрішній токен інтеграції — вмикає кнопку «Notion» у спливному вікні, яка додає експорт на вказану нижче сторінку." }, "settingsNotionTokenLink": { "message": "Створити →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Повідомити про помилку" + }, + "popupBatchExportToggle": { + "message": "📦 Пакетний експорт попередніх розмов…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Експортувати позначені розмови в один ZIP-файл" + }, + "popupBatchExportStartBtn": { + "message": "Експортувати вибране як ZIP" + }, + "popupBatchExportCount": { + "message": "Вибрано $1 з $2", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Спочатку виберіть хоча б одну розмову." + }, + "popupBatchExportStarting": { + "message": "Експортування $1 розмов…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Експортування $1 з $2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Пакетний експорт завершено: успішно $1, пропущено $2", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Не вдалося виконати пакетний експорт" + }, + "popupExportMoreOptions": { + "message": "Додаткові параметри експорту" + }, + "popupBtnAllShort": { + "message": "Все" + }, + "popupExportGoBtn": { + "message": "Експортувати" } } diff --git a/_locales/vi/messages.json b/_locales/vi/messages.json index 8da5ef3..69d8adb 100644 --- a/_locales/vi/messages.json +++ b/_locales/vi/messages.json @@ -114,19 +114,19 @@ "message": "Sao chép MD" }, "popupBtnCopyHtml": { - "message": "Sao chép HTML" + "message": "Sao chép" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ Xuất tất cả (MD + DOCX + ZIP)" + "message": "Xuất tất cả (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "Chỉ xuất các tin nhắn được thêm kể từ lần xuất gần nhất", @@ -610,7 +610,7 @@ "message": "Token tích hợp Notion" }, "settingsNotionTokenDesc": { - "message": "Token tích hợp nội bộ — kích hoạt nút \"Notion ↑\" trong popup, thêm bản xuất vào trang bên dưới." + "message": "Token tích hợp nội bộ — kích hoạt nút \"Notion\" trong popup, thêm bản xuất vào trang bên dưới." }, "settingsNotionTokenLink": { "message": "Tạo một token →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "Báo cáo lỗi" + }, + "popupBatchExportToggle": { + "message": "📦 Xuất hàng loạt các cuộc trò chuyện trước đây…" + }, + "popupBatchExportStartBtnTitle": { + "message": "Xuất các cuộc trò chuyện đã đánh dấu chọn thành một tệp ZIP duy nhất" + }, + "popupBatchExportStartBtn": { + "message": "Xuất các mục đã chọn sang ZIP" + }, + "popupBatchExportCount": { + "message": "Đã chọn $1/$2", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "Hãy chọn ít nhất một cuộc trò chuyện trước." + }, + "popupBatchExportStarting": { + "message": "Đang xuất $1 cuộc trò chuyện…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "Đang xuất $1/$2: $3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "Xuất hàng loạt hoàn tất: $1 thành công, $2 bị bỏ qua", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "Xuất hàng loạt thất bại" + }, + "popupExportMoreOptions": { + "message": "Thêm tùy chọn xuất" + }, + "popupBtnAllShort": { + "message": "Tất cả" + }, + "popupExportGoBtn": { + "message": "Xuất" } } diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index 84723da..3fe5adf 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -114,19 +114,19 @@ "message": "复制 MD" }, "popupBtnCopyHtml": { - "message": "复制 HTML" + "message": "复制" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ 导出全部(MD + DOCX + ZIP)" + "message": "导出全部(MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "仅导出自上次导出以来新增的消息", @@ -610,7 +610,7 @@ "message": "Notion 集成令牌" }, "settingsNotionTokenDesc": { - "message": "内部集成令牌 — 用于启用弹出窗口中的“Notion ↑”按钮,将导出内容追加到下方页面。" + "message": "内部集成令牌 — 用于启用弹出窗口中的\"Notion\"按钮,将导出内容追加到下方页面。" }, "settingsNotionTokenLink": { "message": "创建令牌 →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "报告问题" + }, + "popupBatchExportToggle": { + "message": "📦 批量导出历史对话…" + }, + "popupBatchExportStartBtnTitle": { + "message": "将勾选的对话导出为一个 ZIP 压缩包" + }, + "popupBatchExportStartBtn": { + "message": "导出所选为 ZIP" + }, + "popupBatchExportCount": { + "message": "已选择 $2 项中的 $1 项", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "请先至少选择一个对话。" + }, + "popupBatchExportStarting": { + "message": "正在导出 $1 个对话…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "正在导出第 $1 项(共 $2 项):$3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "批量导出完成:成功 $1 个,跳过 $2 个", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "批量导出失败" + }, + "popupExportMoreOptions": { + "message": "更多导出选项" + }, + "popupBtnAllShort": { + "message": "全部" + }, + "popupExportGoBtn": { + "message": "导出" } } diff --git a/_locales/zh_TW/messages.json b/_locales/zh_TW/messages.json index ba557e6..304a7b4 100644 --- a/_locales/zh_TW/messages.json +++ b/_locales/zh_TW/messages.json @@ -114,19 +114,19 @@ "message": "複製 MD" }, "popupBtnCopyHtml": { - "message": "複製 HTML" + "message": "複製" }, "popupBtnZip": { "message": "ZIP" }, "popupBtnGist": { - "message": "Gist ↑" + "message": "Gist" }, "popupBtnNotion": { - "message": "Notion ↑" + "message": "Notion" }, "popupBtnExportAll": { - "message": "⬇ 匯出全部 (MD + DOCX + ZIP)" + "message": "匯出全部 (MD + DOCX + ZIP)" }, "popupIncrementalBtnTitle": { "message": "僅匯出自上次匯出後新增的訊息", @@ -610,7 +610,7 @@ "message": "Notion 整合權杖" }, "settingsNotionTokenDesc": { - "message": "內部整合權杖 — 可啟用彈出視窗中的「Notion ↑」按鈕,將匯出內容附加到下方頁面。" + "message": "內部整合權杖 — 可啟用彈出視窗中的「Notion」按鈕,將匯出內容附加到下方頁面。" }, "settingsNotionTokenLink": { "message": "建立權杖 →" @@ -1035,5 +1035,73 @@ }, "popupBtnReportBug": { "message": "回報問題" + }, + "popupBatchExportToggle": { + "message": "📦 批次匯出過去的對話…" + }, + "popupBatchExportStartBtnTitle": { + "message": "將勾選的對話匯出為單一 ZIP" + }, + "popupBatchExportStartBtn": { + "message": "將所選匯出為 ZIP" + }, + "popupBatchExportCount": { + "message": "已選取 $2 項中的 $1 項", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportNoneSelected": { + "message": "請先至少選取一個對話。" + }, + "popupBatchExportStarting": { + "message": "正在匯出 $1 個對話…", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "popupBatchExportProgress": { + "message": "正在匯出第 $1 項(共 $2 項):$3", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + }, + "3": { + "content": "$3" + } + } + }, + "popupBatchExportDone": { + "message": "批次匯出完成:成功 $1 個,略過 $2 個", + "placeholders": { + "1": { + "content": "$1" + }, + "2": { + "content": "$2" + } + } + }, + "popupBatchExportFailed": { + "message": "批次匯出失敗" + }, + "popupExportMoreOptions": { + "message": "更多匯出選項" + }, + "popupBtnAllShort": { + "message": "全部" + }, + "popupExportGoBtn": { + "message": "匯出" } } diff --git a/background.js b/background.js index baeb5ec..6c49178 100644 --- a/background.js +++ b/background.js @@ -93,6 +93,148 @@ api.runtime.onMessage.addListener((message, sender) => { })(); }); +// ─── Batch export (Batch 8): sequential background-tab automation ──────── +// popup.js sends { action: 'startBatchExport', conversations: [{title,url}], +// originTabId } after the user ticks conversations from the CURRENT tab's +// own history sidebar (populated via content.js's getConversationList() — +// see src/content.js and planning/TODOs.md Batch 8). Conversations are +// visited ONE AT A TIME in a hidden background tab (not parallel — avoids +// overwhelming per-platform lazy-load behavior and looks less bot-like than +// bursty parallel tab creation), extracted exactly like every other +// extraction path, then aggregated into a single ZIP (one .md file per +// conversation) via the existing buildZip()/buildFilename(). Runs entirely +// in this service worker so it keeps going even if the popup that started +// it gets closed. +// +// Known open questions, not yet resolved — see TODOs.md Batch 8 for detail: +// realistic per-platform load timeouts (the fixed values below are a +// reasonable starting guess, not measured against real slow-loading +// accounts) and how many conversations per run is safe before it risks +// looking bot-like or tripping a platform rate limit. Flagging this +// explicitly rather than silently guessing it away — needs live testing +// against a real account with enough history to matter before this is +// something to rely on for a large batch. + +/** Resolve once tabId finishes loading, or reject after timeoutMs. */ +function waitForTabLoad(tabId, timeoutMs) { + return new Promise((resolve, reject) => { + let done = false; + const timer = setTimeout(() => { + if (done) return; + done = true; + api.tabs.onUpdated.removeListener(listener); + reject(new Error('tab load timeout')); + }, timeoutMs); + + function finish() { + if (done) return; + done = true; + clearTimeout(timer); + api.tabs.onUpdated.removeListener(listener); + resolve(); + } + + function listener(id, info) { + if (id === tabId && info.status === 'complete') finish(); + } + api.tabs.onUpdated.addListener(listener); + + // Race-safe: the tab may already be "complete" by the time we attach + // (unlikely for a just-created tab, but cheap to guard against). + api.tabs.get(tabId).then(t => { if (t.status === 'complete') finish(); }).catch(() => {}); + }); +} + +/** + * Run the actual batch: visit each conversation in a hidden tab, extract, + * collect into one ZIP, download it, then report a { succeeded, skipped, + * total } summary. Never throws — per-conversation failures are caught and + * counted as skipped, matching the "errors are per-conversation, not fatal + * to the run" design. + */ +async function runBatchExport(conversations, originTabId) { + const stored = await api.storage.local.get('inkpour_settings'); + const settings = Object.assign( + { yamlFrontMatter: false, generateTOC: false, filenameTemplate: '{platform}-{title}', downloadSubfolder: '', obsidianVault: '', obsidianTags: false, scrubSecrets: true }, + stored?.inkpour_settings ?? {} + ); + + function toast(text, variant) { + if (!originTabId) return; + api.tabs.sendMessage(originTabId, { action: 'showToast', text, variant }).catch(() => {}); + } + + const files = []; + const usedNames = new Set(); + let succeeded = 0; + let skipped = 0; + + for (let i = 0; i < conversations.length; i++) { + const { title: sidebarTitle, url } = conversations[i]; + toast(api.i18n.getMessage('popupBatchExportProgress', [String(i + 1), String(conversations.length), sidebarTitle || ''])); + + let tab = null; + try { + tab = await api.tabs.create({ url, active: false }); + await waitForTabLoad(tab.id, 20000); + // Give the SPA a moment to hydrate/render turns after the browser's + // own "complete" event fires — chatgpt/gemini/aistudio are already + // known to be slow lazy-loaders elsewhere in this codebase (see the + // streaming/auto-scroll progress work). Not yet tuned per platform. + await new Promise(r => setTimeout(r, 1500)); + + const response = await Promise.race([ + api.tabs.sendMessage(tab.id, { action: 'extract' }), + new Promise((_, rej) => setTimeout(() => rej(new Error('extract timeout')), 15000)), + ]); + + if (!response?.messages?.length) { + throw new Error(response?.error || 'empty extraction'); + } + + const sourceUrl = url; + const wordCount = response.messages.reduce((s, m) => s + m.content.trim().split(/\s+/).filter(Boolean).length, 0); + let name = buildFilename(settings.filenameTemplate, response.platform, response.filename, sourceUrl, wordCount, response.messages.length); + let unique = name, n = 2; + while (usedNames.has(unique)) unique = `${name}-${n++}`; // two conversations can share a title + usedNames.add(unique); + + const md = buildMarkdown(response.messages, response.title, response.site, settings, sourceUrl); + files.push({ name: unique + '.md', content: md }); + succeeded++; + } catch { + skipped++; + } finally { + if (tab) api.tabs.remove(tab.id).catch(() => {}); + await new Promise(r => setTimeout(r, 800)); // don't hammer the site between tabs + } + } + + if (files.length) { + const zipBytes = buildZip(files); + const b64 = uint8ToBase64(zipBytes); + const url = 'data:application/zip;base64,' + b64; + const stamp = new Date().toISOString().slice(0, 10); + safeDownload(originTabId, url, withSubfolder(settings, `inkpour-batch-${stamp}.zip`)); + } + + toast( + api.i18n.getMessage('popupBatchExportDone', [String(succeeded), String(skipped)]), + skipped > 0 && succeeded === 0 ? 'error' : (skipped > 0 ? 'info' : 'success') + ); + + return { succeeded, skipped, total: conversations.length }; +} + +api.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.action !== 'startBatchExport' || !Array.isArray(message.conversations)) return; + const originTabId = message.originTabId ?? sender.tab?.id; + runBatchExport(message.conversations, originTabId) + .then(result => sendResponse({ ok: true, ...result })) + .catch(err => sendResponse({ ok: false, error: err.message })); + return true; // async +}); + // ─── Context menu setup ─────────────────────────────────────────────────── api.runtime.onInstalled.addListener(() => { @@ -455,9 +597,31 @@ api.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } }); -// ─── Action badge — show "ON" on supported AI chat pages ────────────────── +// ─── Action badge/icon — signal "you can export this page" ──────────────── // Canonical source of truth is supported-sites.json — this flat array is // derived from it (service workers can't fetch local extension files at runtime). +// +// The signal used to be a small native "ON" text badge (setBadgeText) in +// the toolbar icon's bottom-right corner. Feedback from real usage: that +// corner is fixed-size by the browser chrome itself — no manifest/API +// setting can make native badge text or its background any bigger, so it +// read as "a tiny green dot" next to other extensions (uBlock Origin, +// Bitwarden) that show a much more noticeable count badge. Since the badge +// canvas itself can't grow, the fix moves the signal into the icon bitmap +// instead, which we fully control: icons/icon-*-active.png (generated once, +// checked into the repo — see icons/ directory) is the same logo art with +// its background hue-shifted from purple/blue to green (a first attempt +// that overlaid a corner checkmark badge instead was tried and rejected — +// it visibly clipped/overlapped the logo at 32px, so a full-icon recolor +// replaced it: the whole shape stays intact and legible at every size, +// down to 16px, since nothing is drawn on top of it). Swapped in via +// api.action.setIcon() per-tab. This is still a purely passive, no-click +// state change — same mechanism as the old badge, just a bigger, more +// legible visual result. No native badge text is set any more for the +// supported case, to avoid stacking an indicator on top of the recolored icon. + +const ICON_DEFAULT = { 16: 'icons/icon-16.png', 32: 'icons/icon-32.png', 48: 'icons/icon-48.png', 128: 'icons/icon-128.png' }; +const ICON_ACTIVE = { 16: 'icons/icon-16-active.png', 32: 'icons/icon-32-active.png', 48: 'icons/icon-48-active.png', 128: 'icons/icon-128-active.png' }; const SUPPORTED_HOSTS = [ 'chatgpt.com', 'chat.openai.com', @@ -484,18 +648,25 @@ const SUPPORTED_HOSTS = [ ]; function updateBadge(tabId, url) { - if (!api.action?.setBadgeText) return; // not available in all browsers/contexts + if (!api.action) return; // not available in all browsers/contexts let isSupported = false; try { const hostname = new URL(url).hostname; isSupported = SUPPORTED_HOSTS.some(h => hostname === h || hostname.endsWith('.' + h)); } catch { /* not a real URL */ } - if (isSupported) { - api.action.setBadgeText({ text: 'ON', tabId }); - api.action.setBadgeBackgroundColor({ color: '#16a34a', tabId }); // green - } else { - api.action.setBadgeText({ text: '', tabId }); + if (api.action.setIcon) { + // Firefox MV3 support for per-tab setIcon is recent-ish and some very + // old browser builds may reject an unknown tabId (already-closed tab + // raced with this callback) — best-effort, never throw. + api.action.setIcon({ tabId, path: isSupported ? ICON_ACTIVE : ICON_DEFAULT }).catch(() => {}); + } + // No native badge text any more (see comment above) — the icon itself + // carries the signal now. Still clear any leftover text from a version + // that set it previously (e.g. right after an extension update), so + // nobody gets stuck with a stale "ON" badge sitting on top of the new icon. + if (api.action.setBadgeText) { + api.action.setBadgeText({ text: '', tabId }).catch(() => {}); } } @@ -513,3 +684,23 @@ api.tabs.onActivated.addListener(async ({ tabId }) => { if (tab?.url) updateBadge(tabId, tab.url); } catch { /* tab may be gone */ } }); + +// Sync every already-open tab's icon on install/update/reload, and on +// browser startup. Without this, a tab that was already open and already +// the active tab BEFORE the extension loaded/reloaded never gets an icon +// update — neither onUpdated (only fires on navigation) nor onActivated +// (only fires on switching TO a tab) fire just because the extension +// itself reloaded. Confirmed live: reloading the unpacked extension while +// sitting on an already-open, already-focused ChatGPT/Claude tab left the +// icon on its default (non-green) state until the page was reloaded or the +// user switched away and back — this closes that gap. +async function syncAllTabIcons() { + try { + const tabs = await api.tabs.query({}); + for (const tab of tabs) { + if (tab.id != null && tab.url) updateBadge(tab.id, tab.url); + } + } catch { /* best-effort */ } +} +api.runtime.onInstalled.addListener(() => { syncAllTabIcons(); }); +api.runtime.onStartup?.addListener(() => { syncAllTabIcons(); }); diff --git a/icons/icon-128-active.png b/icons/icon-128-active.png new file mode 100644 index 0000000..dbcc5c3 Binary files /dev/null and b/icons/icon-128-active.png differ diff --git a/icons/icon-16-active.png b/icons/icon-16-active.png new file mode 100644 index 0000000..cff07ee Binary files /dev/null and b/icons/icon-16-active.png differ diff --git a/icons/icon-32-active.png b/icons/icon-32-active.png new file mode 100644 index 0000000..da5eaab Binary files /dev/null and b/icons/icon-32-active.png differ diff --git a/icons/icon-48-active.png b/icons/icon-48-active.png new file mode 100644 index 0000000..f1b8236 Binary files /dev/null and b/icons/icon-48-active.png differ diff --git a/icons/icon-96-active.png b/icons/icon-96-active.png new file mode 100644 index 0000000..d779ab4 Binary files /dev/null and b/icons/icon-96-active.png differ diff --git a/manifest.json b/manifest.json index 01b62df..30db45a 100644 --- a/manifest.json +++ b/manifest.json @@ -51,7 +51,8 @@ "*://www.character.ai/*", "*://coral.cohere.com/*", "*://pi.ai/*", - "*://api.github.com/*" + "*://api.github.com/*", + "*://api.notion.com/*" ], "background": { diff --git a/planning/TODOs.md b/planning/TODOs.md index 7048cb9..0176100 100644 --- a/planning/TODOs.md +++ b/planning/TODOs.md @@ -24,9 +24,23 @@ path is one self-contained click handler (settings.js:138–162) building one text fields debounce, Save button flushes a pending debounce immediately). - [x] **XS** Sticky save bar: `.save-row { position: sticky; bottom: 0; }` + background/top border. Do alongside autosave — it becomes the toast's home. -- [ ] **S** (optional) Collapsible sections: `
` per `
` +- [x] **S** (optional) Collapsible sections: `
` per `
` (4 sections, settings.html:193–392). Scannability only — doesn't fix "forgot to save" by itself; skip if session budget is tight. + Done — all 5 sections (Language, Export, Direct-to-vault, Integrations, + Advanced — one more than the original note counted, since Direct-to-vault + landed later in Batch 6) converted from `

` to + `

`, all starting + `open` so the default view is pixel-identical to before. Custom chevron + marker (rotates on open/close) replaces the native `::marker` triangle. + The Direct-to-vault section's existing `hidden`/`style.display` Chrome- + only feature-detect toggle (settings.js `initVaultSection()`) is + untouched and confirmed still correct — `hidden`/`display` and `
` are independent concerns, verified via a quick JSDOM parse (5 + `details.settings-section` elements, correct open/hidden state each, zero + leftover `
` tags). No JS or test file referenced the `
` + tag name, so nothing else needed updating; full suite still 281 passed, + 0 failed. ## Batch 2 — Google AI Mode turn-duplication bug (dedicated session; src/content.js + new fixture) - [x] **M** Extractor duplicates every turn: each exchange appears twice (once @@ -121,6 +135,282 @@ path is one self-contained click handler (settings.js:138–162) building one footer, wired up and i18n'd across all locales (`historyLifetimeStatsOne`/ `historyLifetimeStatsOther`). No code changed for this item. +## Batch 4b — Bigger "you can export this" toolbar signal (Stefan's UX feedback, 2026-07) +- [x] **S** Stefan compared the existing "ON" badge to uBlock Origin/Bitwarden's + much more noticeable toolbar badges and asked for something bigger — but + explicitly not a number, and no click/interaction needed on supported + pages (it should stay a passive state signal, same as today). + Done — the native OS badge corner (`action.setBadgeText`) is fixed-size by + the browser itself; no manifest/API setting can make its text or + background any bigger, so that lever was a dead end. First attempt baked + a green checkmark badge into the icon's corner — Stefan flagged (live, + looking at the real 32px rendering) that it visibly overlapped/clipped + the logo's arrow at that size, which is a real regression in a different + direction (legible size fixed, but now covering the brand mark). Replaced + with a full-icon recolor instead: `icons/icon-{16,32,48,96,128}-active.png` + (generated via Pillow) is the *same* logo art, hue-shifted from its + original purple/blue gradient to green (`#16a34a`'s hue, ≈142°) — nothing + drawn on top, so the arrow/lines stay 100% intact and legible at every + size including 16px, confirmed by inspecting a 16x nearest-neighbor + upscale of the 16px output. Implementation: convert to HSV, shift the H + channel to the target hue only for pixels above a saturation threshold + (~30/255 — cleanly separates the colorful background gradient, sat. + ~146-148, from the pure-white glyph pixels, sat. 0), leaving S/V + untouched so the original gradient's shading/highlight pattern is + preserved, just in green instead of purple/blue. Swapped in per-tab via + `api.action.setIcon()` in `background.js`'s existing `updateBadge()` — + same passive, no-click mechanism as before, just operating on the icon + bitmap (which we fully control) instead of the OS-constrained badge + overlay. Dropped the native `setBadgeText('ON', …)` call entirely for the + supported case (would otherwise sit on top of the recolored icon) but + kept an explicit `setBadgeText({text:''})` call to clear out any stale + "ON" text a previous version of the extension may have left behind on an + already-open tab after updating. 5 new JSDOM tests guard against this + silently regressing: all 4 manifest icon sizes have a matching `-active` + file referenced in `background.js`, every referenced file exists/is + non-empty, each active variant is confirmed to actually differ from its + default counterpart (byte comparison — catches an accidental + copy-paste-without-editing mistake), each is a structurally valid PNG at + its declared size (parsed the IHDR chunk directly, no dependency added), + and the stale-badge-clearing call is still present. Also added the 4 + active icon files to the CI "required files" check + (`.github/workflows/ci.yml`) alongside the existing icon-48/icon-96 + entries. Full suite 281→286 passed, 0 failed (unchanged by the recolor + rework — same 5 tests, same file names, just different pixel content). + **Real bug found and fixed after Stefan reloaded the unpacked extension + and didn't see the green icon at all**: `updateBadge()` only ran from + `tabs.onUpdated` (fires on navigation) and `tabs.onActivated` (fires on + switching TO a tab) — reloading the extension itself, while an + already-open tab was already the focused one, triggers neither, so that + tab's icon just never got told to update. Fixed with a new + `syncAllTabIcons()` that queries every open tab and runs the same + `updateBadge()` on each, wired to both `runtime.onInstalled` (fires on + install/update/every unpacked-reload) and `runtime.onStartup` (browser + restart, restoring previously-open tabs). 2 more JSDOM tests added (288 + total, 0 failed) checking both listeners are wired and that the sync + queries *all* tabs, not just the active one. Still **not yet verified**: + how the green actually reads in a real Chrome/Firefox toolbar (dark vs. + light toolbar theme, native DPI) — image generation and JSDOM-level + checks were done in this sandbox, but there's no way to render an actual + browser toolbar here. The corner-badge version was already caught and + rejected this way (Stefan looking at the real rendering, not something a + screenshot in this sandbox could have caught either), so the same live + look is worth a second glance after reloading again with this fix. + +## Batch 4c — Popup layout: split export button replaces the button grid (Stefan's UX feedback, 2026-07) +- [x] **M** Stefan flagged the popup as crowded — a screenshot showed up to + 11 buttons visible at once (MD/PDF/HTML/JSON/DOCX, Copy MD/Copy HTML/ZIP, + Gist↑/Notion↑ once configured, Export All). Brainstormed three directions + (overflow menu / format-selector + single button / just tighten spacing); + Stefan picked a synthesis: a GitHub-style split button (primary face + + caret) where the caret opens a menu holding everything else, including + folding Gist/Notion into that same menu instead of separate always-there + buttons. Design choices settled during the brainstorm: the primary face + remembers the most recently used format (not a static setting), Copy MD/ + Copy HTML stay as plain menu rows (no separate copy-vs-download icon + toggle), and the other popup sections (batch export, select-messages, + notes) were explicitly left out of scope. + Done — `popup.html`'s "Export as" label + three `.btn-group` rows + (formats / copy+zip+gist+notion / Export All) replaced with a + `.split-export` control (`#exportPrimaryBtn` + `#exportCaretBtn`) and a + collapsible `#exportMenu`. Critically, **the menu contains the exact same + 11 button elements, same ids** (`mdBtn`, `pdfBtn`, …, `gistBtn`, + `notionBtn`) that used to sit directly in the grid — only their + container/CSS class changed from `.btn` grid cells to `.export-menu-item` + rows — so every existing click handler in `popup.js` (extraction, + vault-write branching, Gist/Notion upload, webhook firing, history + persistence, etc.) keeps running completely unmodified. The redesign + only adds a thin layer on top: + - `FORMAT_TO_BTN` maps each format string to its (now-hidden-until- + opened) button element. + - `exportPrimaryBtn`'s click just does + `FORMAT_TO_BTN[preferredFormat]?.click()` — a proxy dispatch to the + real, unmodified handler. + - Each of the 11 buttons gets one extra lightweight listener (alongside + its pre-existing real handler — multiple listeners on one element + don't interfere with each other) that updates `preferredFormat` and + closes the menu; the real export logic is untouched. + - `preferredFormat` is seeded on popup open from `inkpour_last_export` + .format — a field `saveLastExport()` already wrote on every successful + export, reused as-is, no new storage key needed — falling back to the + existing "Default format" setting before anything's ever been + exported. Nested inside the existing settings-load callback so the + fallback value is guaranteed to be the real configured default, not + the synchronous placeholder. + - `setLoading()` (the one shared helper every handler already calls) got + one additive line mirroring its disabled/spinner state onto + `exportPrimaryBtn`, so the visible button shows progress regardless of + whether the action was triggered via the primary face or by opening + the menu and clicking a row directly. + - Gist/Notion's existing show-only-when-configured gating + (`gistBtn.hidden`/`notionBtn.hidden`) is completely unchanged — they + just now toggle visibility of a menu row instead of a grid button. + - Menu closes on: picking any item, clicking anywhere outside + `.split-export`/`.export-menu`, or pressing Escape. + - 2 new i18n keys (`popupExportMoreOptions` — the caret's tooltip; + `popupBtnAllShort` — a short "All" label for the primary face when the + preferred format is Export All, since its real button's full label + "⬇ Export All (MD + DOCX + ZIP)" is too long for the compact face), + translated to all 26 locales (25 via subagent, matching each locale's + existing `popupBtnExportAll` tone, spot-checked + full key-set diff — + zero mismatches). Every other menu label reuses its pre-existing i18n + key verbatim — zero new translation work needed for those. + - 8 new structural JSDOM tests (popup.js/popup.html aren't executed by + this harness — same `chrome.tabs`/`chrome.runtime`-dependency reason + background.js has none either — so these parse popup.html and grep + popup.js's source instead): primary face/caret/menu all exist, all 11 + original buttons still exist *inside* the menu with their original + ids, the old grid/label classes are gone, debug-mode buttons were left + alone outside the menu, `FORMAT_TO_BTN` covers all 11 formats, + `setLoading()` mirrors onto the primary face, the primary face proxies + to the preferred format, and the preferred-format seeding logic is + present. Full suite 288→296 passed, 0 failed. + - Updated the 5 Playwright e2e tests that referenced the old grid + directly (`#mdBtn`/`.export-label` visible-by-default assertions) to + match the new structure, and added 5 new ones covering the menu + open/close/pick behavior specifically. **Not run in this sandbox** — + same Playwright Chromium-binary-download network restriction as the + batch-export toggle test earlier; syntax-verified via `node -c` only. + Needs a real run in CI or on Stefan's machine. + - **Not yet verified**: actual visual/spacing rendering in a real + browser popup (320px wide) — CSS was written to reuse existing + variables/patterns (`.btn`'s spinner/loading convention, the existing + accent color scheme) and reviewed for cascade-order correctness (the + `.export-menu-item` overrides are deliberately placed after `.btn` in + the stylesheet so they win at equal specificity — verified by parsing + popup.html, not by rendering it), but there's no way to screenshot a + real popup in this sandbox. Please take a look after reloading. + +## Batch 4d — Popup export picker rework (real usage feedback on Batch 4c, 2026-07) +- [x] **M** Stefan reloaded the split-button popup from Batch 4c and reported + real usage feedback: the design felt "puristic"/unintuitive; Copy MD and + ZIP were his two most-used actions and got buried in the collapsed menu + when they used to be one click away; and, most importantly, picking a + format in the menu fired the real export/upload *instantly* — no chance + to reconsider, which he flagged as a real risk for the Gist/Notion upload + case in particular ("this automagic export might annoy people if they + just selected something and it fires without any chance to abort"). + Also floated, not yet scoped/built: a Settings option letting each user + choose which 1-2 formats act as their own quick-access default(s) (e.g. + "MD+PDF" vs "MD+DOCX") — flagged below as a good follow-up, deliberately + not bundled into this pass. + Done — reworked the picker into two decoupled pieces: + - **Quick-actions row** (`#copyBtn`, `#zipBtn`): Copy MD and ZIP moved + back out of the menu into their own always-visible, directly-clickable + buttons — ordinary one-click actions, unchanged handlers, matching how + they worked before Batch 4c. + - **Picker + Export button** (`#exportSelectBtn` + `#exportGoBtn`) for + everything else (MD/PDF/HTML/JSON/DOCX/Copy HTML/Export All/Gist/ + Notion): clicking a row in `#exportMenu` now only calls + `setSelectedFormat()` — updates the selector's label and the row's + `.selected` highlight — and does **not** touch the real button at all. + The real export/copy/upload logic still lives, byte-for-byte + unchanged, in the original `mdBtn`/`pdfBtn`/…/`gistBtn`/`notionBtn` + elements, just relocated into a permanently-`hidden` container + (`#realExportActions`) so they can no longer be reached by a direct + user click — the *only* thing that ever fires one now is + `exportGoBtn`'s click handler, which proxies `FORMAT_TO_BTN + [selectedFormat]?.click()`. This is a stronger decoupling than "add an + extra listener" (Batch 4c's approach): the menu rows are brand-new + inert elements (`data-format` attribute only, no shared ids with the + real buttons), so there's no event-ordering subtlety to reason about — + a menu-row click structurally cannot reach a real handler. + - Gist/Notion's show-only-when-configured gating moved from + `gistBtn.hidden`/`notionBtn.hidden` (now moot — those live inside the + unconditionally-hidden `#realExportActions`) to the new menu stand-ins + `#gistMenuOption`/`#notionMenuOption`. + - Selection seeding logic unchanged in spirit (still reads + `inkpour_last_export.format`, falls back to Settings' default format) + but now falls back further to a hardcoded `'md'` if the last-used + format was `copy-md` or `zip` — formats no longer in `FORMAT_TO_BTN` + since they're quick buttons, not picker options. + - 1 new i18n key (`popupExportGoBtn`, "Export" — the button's label), + translated to all 26 locales (25 via subagent, verified via full + key-set diff + JSON validity check on all 25 files). Reused + `popupBtnAllShort` as-is for the picker's "All" label, same as before. + - Rewrote the JSDOM structural suite (now "Popup export picker + (structure)") to match: real buttons live hidden inside + `#realExportActions`; Copy MD/ZIP are visible outside both the menu + and that hidden container; menu rows are inert and share no ids with + the real buttons; `FORMAT_TO_BTN` explicitly excludes `copy-md`/`zip`; + picking a menu row calls `setSelectedFormat()` and never `.click()`; + only `exportGoBtn`'s handler proxies to the real button. Full suite: + 300 passed, 0 failed. + - Rewrote/added Playwright e2e coverage to match (still **not run in + this sandbox** — Playwright Chromium download blocked, same as every + prior e2e change this project; `node -c` syntax-checked only): quick + buttons + picker visible by default, real buttons hidden, menu opens + with correctly-labelled rows and hidden Gist/Notion rows, picking a + row updates the label/closes the menu *without* changing `#status`, + and a separate test confirms clicking Export afterward is what + actually fires the (now-erroring, blank-context) real action. + - **Not yet built, follow-up idea**: a Settings-page option for + configurable quick-access formats (which 1-2 formats besides Copy MD/ + ZIP get their own always-visible button, per-user). Needs its own + design pass — how many slots, which formats are eligible, UI for + picking them, migration for existing installs — deliberately left out + of this pass rather than bolted on. + - **Not yet verified**: real browser rendering of the new layout (quick + row + picker + Export button stacked), same sandbox limitation as + Batch 4c. Please take a look after reloading. +- [x] **XS** Follow-up from live screenshot review: "copy md vs copy html + via dropdown also feels very counter intuitive" — Copy MD sat as an + always-visible quick button while Copy HTML was buried in the menu under + an unrelated "copy" grouping, with no visible reason for the split. + Considered dropping Copy HTML outright (Stefan's initial reaction — "why + would you want this for html? maybe even drop it as it exists as a file + anyway") but he asked to keep it, reframed as the clipboard alternative + to the HTML *file* export ("export to clipboard sounds pretty self + explaining"). + Done — moved the `copy-html` row in `#exportMenu` to sit directly under + the `html` row (no longer separated from it by the old copy-cluster + divider), gave it a `.sub-item` CSS treatment (indented, slightly + smaller/muted) so it visually reads as "HTML, plus its clipboard + variant" rather than an unrelated peer action, and renamed the label + from "Copy HTML" to "Copy to clipboard" (same `popupBtnCopyHtml` i18n + key, just a new message — no new key needed). Translated the new wording + to all 26 locales (25 via subagent, full-suite + JSON-validity checked). + Copy MD's quick-button position is unchanged — it already reads clearly + on its own. Full suite still 300 passed, 0 failed (no test hardcoded the + old label or menu ordering, so nothing needed rewriting there). +- [x] **S** Two more rounds of feedback on the same screenshot: the export + selector's caret was "tiny, hard to understand how to un-dropdown it", + and singling out "Copy to clipboard" with destination wording made the + *other* rows' silent destination (a downloaded file, or an upload) feel + unexplained by contrast — "not really clear where the exports goes for + the other formats". Brainstormed icon-only vs. label-everything vs. + revert-the-one-row; Stefan picked a symbol/icon language matching what + Gist/Notion's "↑" already implied, plus explicitly asked to make it + future-proof: icons as separate elements, not baked into translated + strings, so a future wording change never again means touching 26 + locale files just to move a symbol around. + Done: + - Selector caret: bigger (10px → 15px) and now rotates 180° when the + menu is open (`.export-select-btn[aria-expanded="true"] + .export-select-caret`), so the same glyph visibly signals both "open" + and "click to close". + - Added a small non-translated inline SVG icon to every quick button and + every menu row: a download-arrow-into-a-tray icon for anything saved + as a file (MD/PDF/HTML/JSON/DOCX/Export All/ZIP), a two-squares copy + icon for anything clipboard-bound (Copy MD, HTML's "Copy" sub-item), + and an upload-arrow-out-of-a-tray icon for anything sent to an + external service (Gist, Notion). These are plain `` markup in + popup.html, entirely outside any `data-i18n` span — no locale file + references them at all. + - Because the icons now carry the destination meaning, stripped the + symbols that used to be baked into the *translated* strings + themselves: `popupBtnExportAll` lost its leading "⬇ ", `popupBtnGist`/ + `popupBtnNotion` lost their trailing " ↑", and `popupBtnCopyHtml` + shortened from "Copy to clipboard" (this session's earlier fix) down + to just "Copy" — the copy icon now says what the wording used to say. + Also fixed `settingsNotionTokenDesc`'s description text, which quoted + the old "Notion ↑" button name. + - All 5 changed keys' values re-translated across all 26 locales (25 via + subagent, verified: full suite passes, no leftover ⬇/↑ characters in + any of the 4 button-label keys via grep, spot-checked JSON validity). + - Full suite: 300 passed, 0 failed. Same sandbox caveat as always for + the actual rendered look — please take a look after reloading. + ## Batch 5 — Notion export (dedicated session; background.js + settings.html/.js + popup.js) - [x] **M → implemented, pending live test** BYO integration token + target page ID in settings, client-side `fetch` to the Notion API. Verified live @@ -231,10 +521,121 @@ path is one self-contained click handler (settings.js:138–162) building one landed immediately before this one on `main`). ## Batch 7 — New extraction surfaces (needs live logged-in pages — Stefan's browser; flag before starting) -- [ ] **L** ChatGPT Canvas export: non-linear side-panel UI, needs its own - extraction rules + fixture. DOM unknown until inspected live. -- [ ] **L** Claude Artifacts: extract as structured blocks alongside the chat, - not as plain code. Same caveat: live DOM inspection required first. +- [x] **L → smaller than scoped, fixed** ChatGPT Canvas export — investigated + live 2026-07 against a real logged-in ChatGPT account, both Canvas variants: + - **Code canvas** (asked ChatGPT to "open canvas and write a python script"): + turned out NOT to need the non-linear side-panel handling the original + note assumed. The code renders via a CodeMirror editor (`.cm-editor`/ + `.cm-content`) nested inside the SAME `
` that's already a normal
+    direct child of the turn's `.markdown` div — no separate panel, no new
+    turn-enumeration logic needed. The existing `case 'pre':` handler in
+    `convertNode()` already extracts CLEAN code (its `querySelector('code')`
+    happens to reach straight through the CodeMirror markup), so there was no
+    "PythonRun" toolbar-text leakage as initially suspected from a raw
+    `textContent` check — that was a red herring from comparing the wrong
+    thing (plain DOM `textContent` vs. what `htmlToMarkdown()` actually
+    produces). The one real, confirmed gap: none of the three existing
+    language-detection heuristics (class, sibling span, hljs) find anything
+    for Canvas blocks, because the language name ("Python") sits as plain text
+    in a `.sticky` toolbar header alongside Copy/Run `
-    
+  
+
+  
+  
+ - +
+ + -
- - - - -
+ + + -
- -
-
@@ -511,6 +710,38 @@

Inkpour

border-radius:7px;padding:4px 0;background:var(--surface);"> + + + +
diff --git a/popup.js b/popup.js index 3e6fc4b..cbb6204 100644 --- a/popup.js +++ b/popup.js @@ -25,6 +25,12 @@ const debugDomBtn = document.getElementById('debugDomBtn'); const reportBugBtn = document.getElementById('reportBugBtn'); const allBtn = document.getElementById('allBtn'); + const exportSelectBtn = document.getElementById('exportSelectBtn'); + const exportSelectedLabel = document.getElementById('exportSelectedLabel'); + const exportGoBtn = document.getElementById('exportGoBtn'); + const exportMenu = document.getElementById('exportMenu'); + const gistMenuOption = document.getElementById('gistMenuOption'); + const notionMenuOption = document.getElementById('notionMenuOption'); const settingsBtn = document.getElementById('settingsBtn'); const historyBtn = document.getElementById('historyBtn'); const settingsBtn2 = document.getElementById('settingsBtn2'); @@ -46,6 +52,14 @@ const selectNoneBtn = document.getElementById('selectNone'); const selectUserBtn = document.getElementById('selectUser'); const selectAIBtn = document.getElementById('selectAI'); + const batchExportToggle = document.getElementById('batchExportToggle'); + const batchExportSection = document.getElementById('batch-export-section'); + const batchExportCount = document.getElementById('batchExportCount'); + const batchExportCheckboxes = document.getElementById('batchExportCheckboxes'); + const batchExportSelectAllBtn = document.getElementById('batchExportSelectAll'); + const batchExportSelectNoneBtn = document.getElementById('batchExportSelectNone'); + const batchExportStartBtn = document.getElementById('batchExportStartBtn'); + const batchExportStatus = document.getElementById('batchExportStatus'); const importBtn = document.getElementById('importBtn'); const importSection = document.getElementById('import-section'); const importText = document.getElementById('importText'); @@ -99,18 +113,33 @@ api.storage.local.get('inkpour_settings', (result) => { userSettings = Object.assign({}, SETTING_DEFAULTS, result?.inkpour_settings ?? {}); - // Highlight default format button - const btnMap = { md: mdBtn, pdf: pdfBtn, html: htmlBtn, json: jsonBtn, zip: zipBtn }; - const defaultBtn = btnMap[userSettings.defaultFormat]; - if (defaultBtn) defaultBtn.classList.add('default-format'); - // Show Gist button only when a token is configured - if (gistBtn && userSettings.githubToken) gistBtn.hidden = false; - // Show Notion button only when both a token and a target page ID are configured - if (notionBtn && userSettings.notionToken && userSettings.notionPageId) notionBtn.hidden = false; + // Highlight default format on the always-visible ZIP quick button; the + // rest of the formats live behind the picker now, where the "selected" + // state (see setSelectedFormat()) already communicates this. + if (userSettings.defaultFormat === 'zip') zipBtn?.classList.add('default-format'); + // Show the Gist/Notion picker rows only when configured. The real + // gistBtn/notionBtn stay hidden unconditionally now (they live inside + // #realExportActions and are only ever .click()-proxied) — it's their + // menu stand-ins that need the token gating. + if (gistMenuOption && userSettings.githubToken) gistMenuOption.hidden = false; + if (notionMenuOption && userSettings.notionToken && userSettings.notionPageId) notionMenuOption.hidden = false; // Debug-mode buttons — "Copy debug info" needs no token (opens/copies // locally); "Report bug" also works without one (opens a pre-filled // GitHub issue the user reviews and submits themselves), so both show // together under the same Debug mode toggle regardless of token state. + // Seed the picker's selection: prefer the most recently used format + // (persisted by saveLastExport() further below on every successful + // export), falling back to this "Default format" setting before + // anything has ever been exported. Nested inside this same callback + // (rather than a separate storage.local.get call) so + // userSettings.defaultFormat is guaranteed to already be the real + // configured value, not the synchronous SETTING_DEFAULTS placeholder, + // by the time it's used as the fallback. + api.storage.local.get('inkpour_last_export', (lastResult) => { + const lastFormat = lastResult?.inkpour_last_export?.format; + const fallback = FORMAT_TO_BTN[userSettings.defaultFormat] ? userSettings.defaultFormat : 'md'; + setSelectedFormat(lastFormat && FORMAT_TO_BTN[lastFormat] ? lastFormat : fallback); + }); if (debugGroupEl && userSettings.debugMode) { debugGroupEl.hidden = false; debugGroupEl.style.display = 'flex'; @@ -349,6 +378,125 @@ return originalTitle; } + // ─── Batch export (Batch 8) ───────────────────────────────────────────── + // Picks multiple PAST conversations from the current tab's own history + // sidebar (ChatGPT/Claude only for now) and exports them all as one ZIP. + // This is strictly additive: getConversationList() (src/content.js) + // returns [] on any unsupported/logged-out page, so batchExportToggle + // simply never appears there — the single-conversation export flow above + // is completely untouched either way. + + let batchExportConversations = []; + + /** Ask the current tab for its history-sidebar conversation list and, if + * any are found, reveal the batch-export toggle button. Best-effort and + * silent on failure — this must never interfere with the main peek/export + * flow above, which already ran (or is running) independently. */ + async function initBatchExport() { + if (!batchExportToggle) return; + try { + const [tab] = await api.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) return; + const response = await api.tabs.sendMessage(tab.id, { action: 'getConversationList' }); + const conversations = response?.conversations ?? []; + if (!conversations.length) return; + batchExportConversations = conversations; + batchExportToggle.hidden = false; + batchExportToggle.style.display = 'block'; + } catch { + // Not a supported page, or content script not ready — stay silent, + // same as runPeek()'s own catch above. + } + } + + function renderBatchExportCheckboxes() { + if (!batchExportCheckboxes) return; + batchExportCheckboxes.textContent = ''; + batchExportConversations.forEach((conv, i) => { + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.idx = String(i); + checkbox.checked = false; // opt-in, not opt-out — this fires N background tabs + checkbox.addEventListener('change', updateBatchExportCount); + + const titleSpan = document.createElement('span'); + titleSpan.className = 'msg-preview'; + titleSpan.textContent = conv.title; + + const row = document.createElement('label'); + row.className = 'msg-row'; + row.append(checkbox, titleSpan); + batchExportCheckboxes.appendChild(row); + }); + updateBatchExportCount(); + } + + function updateBatchExportCount() { + if (!batchExportCheckboxes || !batchExportCount) return; + const checked = batchExportCheckboxes.querySelectorAll('input[type=checkbox]:checked').length; + batchExportCount.textContent = t('popupBatchExportCount', [String(checked), String(batchExportConversations.length)]); + } + + function getSelectedBatchExportConversations() { + const boxes = batchExportCheckboxes?.querySelectorAll('input[type=checkbox]') ?? []; + const selected = []; + boxes.forEach((box, i) => { + if (box.checked && batchExportConversations[i]) selected.push(batchExportConversations[i]); + }); + return selected; + } + + batchExportToggle?.addEventListener('click', () => { + const open = batchExportSection && !batchExportSection.hidden; + if (!open) renderBatchExportCheckboxes(); // (re-)populate on every open, list may be stale + if (batchExportSection) { + batchExportSection.hidden = open; + batchExportSection.style.display = open ? 'none' : 'block'; + } + }); + + batchExportSelectAllBtn?.addEventListener('click', () => { + batchExportCheckboxes?.querySelectorAll('input[type=checkbox]').forEach(b => { b.checked = true; }); + updateBatchExportCount(); + }); + batchExportSelectNoneBtn?.addEventListener('click', () => { + batchExportCheckboxes?.querySelectorAll('input[type=checkbox]').forEach(b => { b.checked = false; }); + updateBatchExportCount(); + }); + + batchExportStartBtn?.addEventListener('click', async () => { + if (!batchExportStatus) return; + const selected = getSelectedBatchExportConversations(); + if (!selected.length) { + batchExportStatus.textContent = t('popupBatchExportNoneSelected'); + return; + } + const [tab] = await api.tabs.query({ active: true, currentWindow: true }); + setLoading(batchExportStartBtn, true); + batchExportStatus.textContent = t('popupBatchExportStarting', [String(selected.length)]); + try { + // background.js owns the actual tab-cycling loop so the run survives + // this popup closing; it reports back once every conversation has + // been tried (success or skip), not incrementally — see + // background.js's runBatchExport() for the per-conversation progress + // toasts it fires on this same origin tab in the meantime. + const result = await api.runtime.sendMessage({ + action: 'startBatchExport', + conversations: selected, + originTabId: tab?.id, + }); + if (result?.ok) { + batchExportStatus.textContent = t('popupBatchExportDone', [String(result.succeeded), String(result.skipped)]); + } else { + batchExportStatus.textContent = result?.error || t('popupBatchExportFailed'); + } + } catch (err) { + batchExportStatus.textContent = err?.message || t('popupBatchExportFailed'); + } finally { + setLoading(batchExportStartBtn, false); + } + }); + async function runPeek() { try { const [tab] = await api.tabs.query({ active: true, currentWindow: true }); @@ -417,6 +565,7 @@ if (onSupportedHost) { await clearImportState(); // live page wins; drop any stale pending import runPeek(); + initBatchExport(); return; } @@ -1308,8 +1457,108 @@ function setLoading(btn, on) { btn.disabled = on; btn.classList.toggle('loading', on); + // Mirror onto the visible Export button (see "Export picker" below) — + // the real buttons this proxies to live hidden in #realExportActions, + // so their own spinner isn't visible on its own. + if (exportGoBtn && Object.values(FORMAT_TO_BTN).includes(btn)) { + exportGoBtn.disabled = on; + exportGoBtn.classList.toggle('loading', on); + } + } + + // ─── Export picker (selector + separate "Export" button) ─────────────── + // Replaces the old instant-fire split button (TODOs.md "Popup layout" + // item). Picking a row in the menu only changes the selection — nothing + // runs until the Export button is pressed. This decouples "choose" from + // "commit" so a stray click while browsing the menu can't fire a Gist/ + // Notion upload (or any other export) with no chance to back out. + // + // Menu rows are plain UI (data-format attribute only, no handler of their + // own); the actual export/copy/upload logic lives, completely unchanged, + // in the hidden buttons in #realExportActions — this map is how the + // Export button finds which one to .click()-proxy to. Copy MD and ZIP + // aren't in this map: they stayed real, directly-clickable, always- + // visible buttons in the quick-actions row (see popup.html), since the + // instant-fire concern was specifically about picking from a dropdown. + const FORMAT_TO_BTN = { + md: mdBtn, + pdf: pdfBtn, + html: htmlBtn, + json: jsonBtn, + docx: docxBtn, + 'copy-html': copyHtmlBtn, + all: allBtn, + gist: gistBtn, + notion: notionBtn, + }; + + let selectedFormat = 'md'; + + function menuItemFor(format) { + return exportMenu?.querySelector(`.export-menu-item[data-format="${format}"]`) || null; + } + + /** Label for the selector face — reuses the menu row's own (already- + * localized) span text, except 'all' which gets its own much shorter + * dedicated string (the real row's label is a full sentence, "⬇ Export + * All (MD + DOCX + ZIP)", too long for the compact selector). */ + function labelFor(format) { + if (format === 'all') return t('popupBtnAllShort'); + return menuItemFor(format)?.querySelector('span:last-child')?.textContent || format.toUpperCase(); } + function setSelectedFormat(format) { + if (!FORMAT_TO_BTN[format]) return; // unknown/stale format string — ignore + selectedFormat = format; + if (exportSelectedLabel) exportSelectedLabel.textContent = labelFor(format); + exportMenu?.querySelectorAll('.export-menu-item').forEach((el) => { + el.classList.toggle('selected', el.dataset.format === format); + }); + } + + function closeExportMenu() { + if (!exportMenu || exportMenu.hidden) return; + exportMenu.hidden = true; + exportMenu.style.display = 'none'; + exportSelectBtn?.setAttribute('aria-expanded', 'false'); + } + + function openExportMenu() { + if (!exportMenu) return; + exportMenu.hidden = false; + exportMenu.style.display = 'flex'; + exportMenu.style.flexDirection = 'column'; + exportSelectBtn?.setAttribute('aria-expanded', 'true'); + } + + exportSelectBtn?.addEventListener('click', () => { + if (exportMenu?.hidden) openExportMenu(); else closeExportMenu(); + }); + + // Picking a row only updates the selection and closes the menu — it never + // touches the real (hidden) button, so nothing fires yet. + exportMenu?.querySelectorAll('.export-menu-item').forEach((el) => { + el.addEventListener('click', () => { + setSelectedFormat(el.dataset.format); + closeExportMenu(); + }); + }); + + // The one and only place a picker-selected format actually fires: proxy a + // real .click() to whichever hidden button matches the current selection. + exportGoBtn?.addEventListener('click', () => { + FORMAT_TO_BTN[selectedFormat]?.click(); + }); + + document.addEventListener('click', (e) => { + if (!exportMenu || exportMenu.hidden) return; + const withinPicker = e.target.closest('.export-picker, .export-menu'); + if (!withinPicker) closeExportMenu(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeExportMenu(); + }); + // ─── Export persistence (last hint + rolling history) ───────────────────── /** diff --git a/settings.html b/settings.html index 308fc0c..6841506 100644 --- a/settings.html +++ b/settings.html @@ -80,16 +80,33 @@ padding: 2px 10px; } - /* ── Section ── */ - section { margin-bottom: 1.75rem; } - section h2 { + /* ── Section (collapsible via
; starts open so nothing about + the default view changes — this is a scannability aid, not a way to + hide settings people already rely on) ── */ + details.settings-section { margin-bottom: 1.75rem; } + details.settings-section summary { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--subtext); margin-bottom: 10px; + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 6px; + user-select: none; + } + details.settings-section summary::-webkit-details-marker { display: none; } + details.settings-section summary::before { + content: '▸'; + display: inline-block; + font-size: 9px; + transition: transform 0.15s; } + details.settings-section[open] summary::before { transform: rotate(90deg); } + details.settings-section summary:hover { opacity: 0.8; } /* ── Field ── */ .field { @@ -212,8 +229,8 @@

Inkpour Settings

-
-

Language

+
+

Language

@@ -222,11 +239,11 @@

Language

-
+
-
-

Export

+
+

Export

@@ -309,15 +326,15 @@

Export

-
+
- +

-
-

Integrations

+
+

Integrations

@@ -423,11 +440,11 @@

Integrations

-
+

-
-

Advanced

+
+

Advanced

@@ -461,7 +478,7 @@

Advanced

PDF import fuzzer →
-
+
diff --git a/src/content.js b/src/content.js index 9d1cc91..de7c821 100644 --- a/src/content.js +++ b/src/content.js @@ -139,6 +139,25 @@ if (/^[a-z][a-z0-9+#.-]{0,20}$/.test(t)) lang = t; } } + // ChatGPT Canvas code blocks: rendered via a CodeMirror editor + // (.cm-editor/.cm-content) nested inside this same
, with the
+        // language name sitting as plain text in a "sticky" toolbar header
+        // alongside Copy/Run buttons — no "language-x" class or preceding
+        // sibling span exists here, so the two checks above always miss it.
+        // Verified live 2026-07: codeEl.textContent is already clean (this
+        // querySelector('code') call reaches through the CodeMirror markup
+        // correctly), so the only actual gap is the missing language tag.
+        // Scoped to only fire when a CodeMirror editor is actually present,
+        // so it can't misfire on some other platform's unrelated ".sticky".
+        if (!lang && node.querySelector('.cm-editor, .cm-content, #code-block-viewer')) {
+          const header = node.querySelector('.sticky');
+          const labelEl = header && Array.from(header.querySelectorAll('div, span'))
+            .find(el => el.children.length === 0 && el.textContent.trim() && !el.closest('button'));
+          if (labelEl) {
+            const t = labelEl.textContent.trim().toLowerCase();
+            if (/^[a-z][a-z0-9+#.-]{0,20}$/.test(t)) lang = t;
+          }
+        }
         const code = (codeEl ?? node).textContent;
         return `\n\n\`\`\`${lang}\n${code.trimEnd()}\n\`\`\`\n\n`;
       }
@@ -336,6 +355,59 @@
     return 'generic';
   }
 
+  // ─── Batch export: history sidebar enumeration (Batch 8 spike) ────────────
+  // Reads the CURRENT tab's own history sidebar and returns the list of past
+  // conversations as { title, url } pairs — the popup's batch-export picker
+  // list. Returns [] when the platform isn't supported for this yet, or when
+  // no sidebar is present (e.g. logged out) — this is also the feature's
+  // natural logged-in detection point: an empty list means "don't show the
+  // batch-export entry point," never an error.
+  //
+  // Selectors confirmed live against real accounts 2026-07 (see planning/
+  // TODOs.md Batch 8 for the investigation notes):
+  //  - ChatGPT: `a[href^="/c/"]`, title from `aria-label` (falls back to
+  //    textContent — aria-label matches visible title exactly today, but a
+  //    future icon/nested-span change to the link could muddy textContent
+  //    without touching aria-label).
+  //  - Claude: `a[href^="/chat/"]`; textContent is DOUBLED (a `.sr-only`
+  //    span plus a sibling `aria-hidden` display span both carry the same
+  //    text), so `.sr-only` must be preferred when present.
+  // Only currently wired for chatgpt/claude, per the batch's own scope note
+  // ("Start with ChatGPT + Claude only").
+  function getConversationList() {
+    const site = detectSite();
+    const seen = new Set();
+    const out  = [];
+
+    function pushUnique(href, rawTitle) {
+      if (!href) return;
+      let url;
+      try {
+        url = new URL(href, location.origin).href;
+      } catch {
+        return;
+      }
+      if (seen.has(url)) return;
+      const title = (rawTitle || '').replace(/\s+/g, ' ').trim();
+      if (!title) return;
+      seen.add(url);
+      out.push({ title, url });
+    }
+
+    if (site === 'chatgpt') {
+      document.querySelectorAll('a[href^="/c/"]').forEach(a => {
+        pushUnique(a.getAttribute('href'), a.getAttribute('aria-label') || a.textContent);
+      });
+    } else if (site === 'claude') {
+      document.querySelectorAll('a[href^="/chat/"]').forEach(a => {
+        const srOnly = a.querySelector('.sr-only');
+        pushUnique(a.getAttribute('href'), srOnly ? srOnly.textContent : a.textContent);
+      });
+    }
+
+    return out;
+  }
+
   // ─── Per-site extractors ──────────────────────────────────────────────────
 
   /** Sort two { el } objects by DOM order */
@@ -2000,6 +2072,7 @@
   // Never used by real extension code.
   if (typeof window !== 'undefined' && window.__inkpourTestHostname !== undefined) {
     window.__inkpourHtmlToMarkdown = htmlToMarkdown;
+    window.__inkpourGetConversationList = getConversationList;
   }
 
   // ─── In-page toast notification ───────────────────────────────────────────
@@ -2182,6 +2255,17 @@
       return;
     }
 
+    // Batch export (Batch 8): popup asks the current tab for its own history
+    // sidebar's conversation list, to populate the picker checkbox list.
+    if (msg.action === 'getConversationList') {
+      try {
+        sendResponse({ conversations: getConversationList() });
+      } catch (err) {
+        sendResponse({ error: err.message, conversations: [] });
+      }
+      return;
+    }
+
     if (msg.action === 'copyToClipboard') {
       navigator.clipboard.writeText(msg.text)
         .then(() => sendResponse({ ok: true }))
diff --git a/test/e2e/popup.spec.js b/test/e2e/popup.spec.js
index 2a348db..55737bd 100644
--- a/test/e2e/popup.spec.js
+++ b/test/e2e/popup.spec.js
@@ -8,20 +8,85 @@
 const { test, expect } = require('../helpers/extension');
 
 test.describe('Popup UI', () => {
-  test('shows all three export buttons', async ({ popupPage }) => {
-    await expect(popupPage.locator('#mdBtn')).toBeVisible();
-    await expect(popupPage.locator('#pdfBtn')).toBeVisible();
-    await expect(popupPage.locator('#htmlBtn')).toBeVisible();
+  // The old always-visible ~11-button grid (TODOs.md "Popup layout" item)
+  // went through two redesigns: first a single instant-fire split button,
+  // then — per live usage feedback — a picker + separate "Export" button,
+  // plus Copy MD/ZIP promoted back out to always-visible quick buttons.
+  // Picking a row in the menu only changes the selection; nothing runs
+  // until Export is clicked. This matters most for Gist/Notion, where
+  // instant-fire on selection left no chance to back out of an upload.
+
+  test('Copy MD and ZIP quick buttons, and the picker + Export button, are visible by default', async ({ popupPage }) => {
+    await expect(popupPage.locator('#copyBtn')).toBeVisible();
+    await expect(popupPage.locator('#zipBtn')).toBeVisible();
+    await expect(popupPage.locator('#exportSelectBtn')).toBeVisible();
+    await expect(popupPage.locator('#exportGoBtn')).toBeVisible();
+    // Fresh profile, nothing exported yet — falls back to the Settings
+    // default format ('md'), whose menu-item label is "MD".
+    await expect(popupPage.locator('#exportSelectedLabel')).toHaveText('MD');
   });
 
-  test('export label reads "Export as"', async ({ popupPage }) => {
-    await expect(popupPage.locator('.export-label')).toHaveText('Export as');
+  test('the old always-visible button grid is gone', async ({ popupPage }) => {
+    await expect(popupPage.locator('.export-label')).toHaveCount(0);
+    await expect(popupPage.locator('.all-group')).toHaveCount(0);
   });
 
-  test('buttons are labelled correctly', async ({ popupPage }) => {
-    await expect(popupPage.locator('#mdBtn span')).toHaveText('Markdown');
-    await expect(popupPage.locator('#pdfBtn span')).toHaveText('PDF');
-    await expect(popupPage.locator('#htmlBtn span')).toHaveText('HTML');
+  test('the real export buttons are hidden — only reachable via the Export button proxy', async ({ popupPage }) => {
+    await expect(popupPage.locator('#realExportActions')).toBeHidden();
+    await expect(popupPage.locator('#mdBtn')).toBeHidden();
+    await expect(popupPage.locator('#pdfBtn')).toBeHidden();
+    await expect(popupPage.locator('#gistBtn')).toBeHidden();
+    await expect(popupPage.locator('#notionBtn')).toBeHidden();
+  });
+
+  test('selector opens a menu with every picker format, correctly labelled', async ({ popupPage }) => {
+    await expect(popupPage.locator('#exportMenu')).toBeHidden();
+    await popupPage.click('#exportSelectBtn');
+    await expect(popupPage.locator('#exportMenu')).toBeVisible();
+    await expect(popupPage.locator('#exportSelectBtn')).toHaveAttribute('aria-expanded', 'true');
+
+    await expect(popupPage.locator('.export-menu-item[data-format="md"]')).toHaveText(/MD/);
+    await expect(popupPage.locator('.export-menu-item[data-format="pdf"]')).toHaveText(/PDF/);
+    await expect(popupPage.locator('.export-menu-item[data-format="html"]')).toHaveText(/HTML/);
+    await expect(popupPage.locator('.export-menu-item[data-format="json"]')).toHaveText(/JSON/);
+    await expect(popupPage.locator('.export-menu-item[data-format="docx"]')).toHaveText(/DOCX/);
+    // Gist/Notion rows stay hidden inside the menu too, same gating as
+    // before — no GitHub/Notion token configured in a fresh test profile.
+    await expect(popupPage.locator('#gistMenuOption')).toBeHidden();
+    await expect(popupPage.locator('#notionMenuOption')).toBeHidden();
+  });
+
+  test('clicking outside the menu closes it', async ({ popupPage }) => {
+    await popupPage.click('#exportSelectBtn');
+    await expect(popupPage.locator('#exportMenu')).toBeVisible();
+    await popupPage.click('#platformIndicator');
+    await expect(popupPage.locator('#exportMenu')).toBeHidden();
+  });
+
+  test('Escape key closes the menu', async ({ popupPage }) => {
+    await popupPage.click('#exportSelectBtn');
+    await expect(popupPage.locator('#exportMenu')).toBeVisible();
+    await popupPage.keyboard.press('Escape');
+    await expect(popupPage.locator('#exportMenu')).toBeHidden();
+  });
+
+  test('picking a menu item updates the selector label and closes the menu, without firing the export', async ({ popupPage }) => {
+    await popupPage.click('#exportSelectBtn');
+    await popupPage.click('.export-menu-item[data-format="pdf"]');
+    await expect(popupPage.locator('#exportMenu')).toBeHidden();
+    await expect(popupPage.locator('#exportSelectedLabel')).toHaveText('PDF');
+    // No status message yet — selecting a format must not have run anything.
+    await expect(popupPage.locator('#status')).toHaveText('');
+  });
+
+  test('pressing Export after picking a format is what actually fires it', async ({ popupPage }) => {
+    await popupPage.click('#exportSelectBtn');
+    await popupPage.click('.export-menu-item[data-format="pdf"]');
+    await expect(popupPage.locator('#status')).toHaveText('');
+    await popupPage.click('#exportGoBtn');
+    // Blank test context — no chat page open — so this should now show an
+    // error status, proving the click actually reached the real pdfBtn.
+    await expect(popupPage.locator('#status')).toHaveClass(/error/, { timeout: 5000 });
   });
 
   test('settings gear button is visible', async ({ popupPage }) => {
@@ -36,19 +101,33 @@ test.describe('Popup UI', () => {
   });
 
   test('shows error when no chat page is open', async ({ popupPage }) => {
-    // Clicking export on a blank context should show an error status
-    await popupPage.click('#mdBtn');
+    // Clicking Export on a blank context should show an error status.
+    // Selector defaults to 'md' in a fresh profile, same action the old
+    // test triggered by clicking #mdBtn directly.
+    await popupPage.click('#exportGoBtn');
     await expect(popupPage.locator('#status')).toHaveClass(/error/, { timeout: 5000 });
     const msg = await popupPage.locator('#status').textContent();
     expect(msg.length).toBeGreaterThan(0);
   });
 
-  test('buttons are disabled during export', async ({ popupPage }) => {
-    await popupPage.click('#mdBtn');
-    // Immediately check — button should be disabled while working
-    await expect(popupPage.locator('#mdBtn')).toBeDisabled();
+  test('Export button is disabled during export', async ({ popupPage }) => {
+    await popupPage.click('#exportGoBtn');
+    // Immediately check — button should be disabled while working (mirrors
+    // the underlying mdBtn's own loading state, see popup.js's setLoading()).
+    await expect(popupPage.locator('#exportGoBtn')).toBeDisabled();
     // Eventually re-enabled after error
-    await expect(popupPage.locator('#mdBtn')).toBeEnabled({ timeout: 5000 });
+    await expect(popupPage.locator('#exportGoBtn')).toBeEnabled({ timeout: 5000 });
+  });
+
+  // Batch 8: getConversationList() (src/content.js) returns [] on any
+  // unsupported/logged-out page — this is the batch-export feature's
+  // feature-detect point, and it must never show up where it can't work.
+  // A blank context (same setup as "shows error when no chat page is open"
+  // above) is the simplest stand-in for "no history sidebar available".
+  test('batch export toggle stays hidden with no chat page open', async ({ popupPage }) => {
+    await popupPage.waitForTimeout(500); // let the popup's init IIFEs settle
+    await expect(popupPage.locator('#batchExportToggle')).toBeHidden();
+    await expect(popupPage.locator('#batch-export-section')).toBeHidden();
   });
 });
 
diff --git a/test/run-jsdom.js b/test/run-jsdom.js
index 5d94329..3649e83 100644
--- a/test/run-jsdom.js
+++ b/test/run-jsdom.js
@@ -674,6 +674,199 @@ async function main() {
     });
   });
 
+  // ── ChatGPT Canvas code block language detection (Batch 7) ──────────────
+  // Structure verified live 2026-07 against a real ChatGPT Canvas code
+  // document: the code renders via a CodeMirror editor (.cm-editor/
+  // .cm-content) nested inside the same outer 
 as a "sticky" toolbar
+  // header (language-name text + Copy/Run buttons). querySelector('code')
+  // already reaches through to clean code text with no toolbar leakage, but
+  // none of the three pre-existing language-detection heuristics (class,
+  // sibling span, hljs) find anything here, so the fenced code block always
+  // came out with no language tag (``` instead of ```python).
+  await suite('htmlToMarkdown — ChatGPT Canvas code block language tag (regression)', async () => {
+    const dom = new JSDOM(`
+      
+
+          
+
+
Python
+
+ + +
+
+
+
+
+
def reverse_string(text):
+    return text[::-1]
+
+
+
+
+
+
console.log("no canvas here");
+
+
+
some other platform's unrelated sticky pre with no CodeMirror editor
+        plain code, no language info anywhere
+
+ `, { url: 'https://chatgpt.com/', runScripts: 'dangerously' }); + dom.window.__inkpourTestHostname = 'chatgpt.com'; + dom.window.HTMLElement.prototype.scrollTo = function () {}; + dom.window.document.documentElement.scrollTo = function () {}; + const ls = []; + dom.window.browser = { runtime: { onMessage: { addListener: fn => ls.push(fn) }, id: 't' }, i18n: mockI18n() }; + dom.window.chrome = dom.window.browser; + const s = dom.window.document.createElement('script'); + s.textContent = CONTENT_JS; + dom.window.document.body.appendChild(s); + await new Promise(r => setTimeout(r, 50)); + const fn = dom.window.__inkpourHtmlToMarkdown; + assert(typeof fn === 'function', 'hook not exposed'); + + await test('Canvas code block gets a python language tag from the sticky header', () => { + const md = fn(dom.window.document.getElementById('canvas')); + assert(md.includes('```python'), `expected \`\`\`python fence. Got: ${md}`); + }); + await test('Canvas code block content is clean (no "PythonRun" toolbar leakage)', () => { + const md = fn(dom.window.document.getElementById('canvas')); + assert(!md.includes('PythonRun'), `toolbar text leaked into code. Got: ${md}`); + assert(md.includes('def reverse_string(text):'), `code content missing. Got: ${md}`); + }); + await test('existing language-class detection still works unaffected', () => { + const md = fn(dom.window.document.getElementById('plain')); + assert(md.includes('```javascript'), `expected \`\`\`javascript fence. Got: ${md}`); + }); + await test('a ".sticky" pre with no CodeMirror editor does not misfire the new heuristic', () => { + const md = fn(dom.window.document.getElementById('unrelated-sticky')); + assert(md.startsWith('\n\n```\n') || md.includes('```\n'), `expected no language tag. Got: ${md}`); + assert(!/```(python|javascript|typescript)/.test(md), `heuristic wrongly fired. Got: ${md}`); + }); + }); + + // ── getConversationList (Batch 8 history-sidebar enumeration spike) ───── + // Fixtures mirror the real DOM shapes confirmed live 2026-07 against + // logged-in ChatGPT and Claude accounts (see planning/TODOs.md Batch 8): + // ChatGPT wraps each link in
  • with a + // data-sidebarItem attribute and a matching aria-label; Claude's link + // textContent is doubled (.sr-only + a sibling aria-hidden display span + // carrying the same text) and must be de-doubled via .sr-only. + await suite('getConversationList — ChatGPT sidebar', async () => { + const dom = new JSDOM(` + + `, { url: 'https://chatgpt.com/', runScripts: 'dangerously' }); + dom.window.__inkpourTestHostname = 'chatgpt.com'; + dom.window.HTMLElement.prototype.scrollTo = function () {}; + dom.window.document.documentElement.scrollTo = function () {}; + const ls = []; + dom.window.browser = { runtime: { onMessage: { addListener: fn => ls.push(fn) }, id: 't' }, i18n: mockI18n() }; + dom.window.chrome = dom.window.browser; + const s = dom.window.document.createElement('script'); + s.textContent = CONTENT_JS; + dom.window.document.body.appendChild(s); + await new Promise(r => setTimeout(r, 50)); + const getConversationList = dom.window.__inkpourGetConversationList; + assert(typeof getConversationList === 'function', '__inkpourGetConversationList not exposed'); + + await test('finds every unique conversation link', () => { + const list = getConversationList(); + assert(list.length === 2, `expected 2 unique conversations, got ${list.length}: ${JSON.stringify(list)}`); + }); + await test('title comes from aria-label', () => { + const list = getConversationList(); + const first = list.find(c => c.url.includes('11111111')); + assert(first && first.title === 'Python String Reversal', `unexpected title: ${JSON.stringify(first)}`); + }); + await test('url is resolved to an absolute /c/ path', () => { + const list = getConversationList(); + assert(list.every(c => /^https:\/\/chatgpt\.com\/c\//.test(c.url)), `unexpected urls: ${JSON.stringify(list)}`); + }); + await test('duplicate href (same conversation linked twice) is de-duplicated', () => { + const list = getConversationList(); + const urls = list.map(c => c.url); + assert(new Set(urls).size === urls.length, `expected no duplicate urls: ${JSON.stringify(list)}`); + }); + }); + + await suite('getConversationList — Claude sidebar', async () => { + const dom = new JSDOM(` + + `, { url: 'https://claude.ai/', runScripts: 'dangerously' }); + dom.window.__inkpourTestHostname = 'claude.ai'; + dom.window.HTMLElement.prototype.scrollTo = function () {}; + dom.window.document.documentElement.scrollTo = function () {}; + const ls = []; + dom.window.browser = { runtime: { onMessage: { addListener: fn => ls.push(fn) }, id: 't' }, i18n: mockI18n() }; + dom.window.chrome = dom.window.browser; + const s = dom.window.document.createElement('script'); + s.textContent = CONTENT_JS; + dom.window.document.body.appendChild(s); + await new Promise(r => setTimeout(r, 50)); + const getConversationList = dom.window.__inkpourGetConversationList; + assert(typeof getConversationList === 'function', '__inkpourGetConversationList not exposed'); + + await test('finds every conversation link', () => { + const list = getConversationList(); + assert(list.length === 2, `expected 2 conversations, got ${list.length}: ${JSON.stringify(list)}`); + }); + await test('title uses .sr-only, not the doubled textContent', () => { + const list = getConversationList(); + const first = list.find(c => c.url.includes('aaaa-1111')); + assert(first && first.title === 'Debugging old Raspberry Pi firmware', + `expected clean single-instance title, got: ${JSON.stringify(first)}`); + }); + }); + + await suite('getConversationList — unsupported/logged-out platform', async () => { + const dom = new JSDOM(` + + `, { url: 'https://gemini.google.com/', runScripts: 'dangerously' }); + dom.window.__inkpourTestHostname = 'gemini.google.com'; + dom.window.HTMLElement.prototype.scrollTo = function () {}; + dom.window.document.documentElement.scrollTo = function () {}; + const ls = []; + dom.window.browser = { runtime: { onMessage: { addListener: fn => ls.push(fn) }, id: 't' }, i18n: mockI18n() }; + dom.window.chrome = dom.window.browser; + const s = dom.window.document.createElement('script'); + s.textContent = CONTENT_JS; + dom.window.document.body.appendChild(s); + await new Promise(r => setTimeout(r, 50)); + const getConversationList = dom.window.__inkpourGetConversationList; + + await test('returns an empty list rather than throwing (feature-detect point)', () => { + const list = getConversationList(); + assert(Array.isArray(list) && list.length === 0, `expected empty array, got: ${JSON.stringify(list)}`); + }); + }); + // ── buildMarkdown (from src/utils.js) ──────────────────────────────────── await suite('buildMarkdown', async () => { const msgs = [ @@ -1923,6 +2116,232 @@ async function main() { assert(!JSON.stringify(result.report.url).includes('?'), 'query string leaked into sanitized url'); }); + // ─── Popup export picker (selector + separate "Export" button) ────────── + // popup.js/popup.html aren't loaded/executed by this JSDOM harness (they + // depend on chrome.tabs/chrome.runtime APIs JSDOM doesn't provide — same + // reason background.js has no JSDOM coverage either, see below). These + // checks are structural: parse popup.html for the expected elements, and + // grep popup.js's source for the wiring that ties them together, so a + // future refactor can't silently drop one without a test noticing. + // + // Design note this suite encodes: picking a row in the menu must only + // change the selection, never fire the real export/copy/upload — that + // only happens when the Export button is clicked. An earlier version + // fired instantly on picking a menu row, which read as "no chance to + // abort" for a Gist/Notion upload in particular; see TODOs.md. + console.log('\nPopup export picker (structure)'); + + const POPUP_HTML = fs.readFileSync(path.resolve(__dirname, '../popup.html'), 'utf8'); + const POPUP_JS = fs.readFileSync(path.resolve(__dirname, '../popup.js'), 'utf8'); + const POPUP_DOM = new JSDOM(POPUP_HTML).window.document; + + // Formats reachable only through the picker + Export button. + const PICKER_MENU_IDS = ['mdBtn', 'pdfBtn', 'htmlBtn', 'jsonBtn', 'docxBtn', 'copyHtmlBtn', 'allBtn', 'gistBtn', 'notionBtn']; + // Formats that stayed real, always-visible, directly-clickable quick buttons. + const QUICK_ACTION_IDS = ['copyBtn', 'zipBtn']; + + await test('selector and Export button both exist', () => { + ['exportSelectBtn', 'exportSelectedLabel', 'exportGoBtn', 'exportMenu'].forEach(id => { + assert(POPUP_DOM.getElementById(id), `#${id} missing from popup.html`); + }); + }); + + await test('Copy MD and ZIP are real, visible, directly-clickable quick buttons (not inside the menu)', () => { + const menu = POPUP_DOM.getElementById('exportMenu'); + const realActions = POPUP_DOM.getElementById('realExportActions'); + QUICK_ACTION_IDS.forEach(id => { + const el = POPUP_DOM.getElementById(id); + assert(el, `#${id} missing from popup.html`); + assert(!menu.contains(el), `#${id} should not be inside #exportMenu`); + assert(!realActions.contains(el), `#${id} should not be hidden inside #realExportActions`); + assert(el.hidden !== true, `#${id} should be visible by default`); + }); + }); + + await test('every other export action exists, hidden, inside #realExportActions with its original id', () => { + const realActions = POPUP_DOM.getElementById('realExportActions'); + assert(realActions, '#realExportActions missing from popup.html'); + assert(realActions.hidden, '#realExportActions should be hidden — never directly clickable'); + PICKER_MENU_IDS.forEach(id => { + const el = POPUP_DOM.getElementById(id); + assert(el, `#${id} missing from popup.html`); + assert(realActions.contains(el), `#${id} should live inside #realExportActions`); + }); + }); + + await test('the menu contains one inert, data-format-only row per picker format (no shared ids with the real buttons)', () => { + const menu = POPUP_DOM.getElementById('exportMenu'); + const formats = ['md', 'pdf', 'html', 'json', 'docx', 'copy-html', 'all', 'gist', 'notion']; + formats.forEach(format => { + const row = menu.querySelector(`.export-menu-item[data-format="${format}"]`); + assert(row, `no .export-menu-item[data-format="${format}"] in #exportMenu`); + assert(!PICKER_MENU_IDS.includes(row.id), `menu row for ${format} should not reuse a real button's id`); + }); + }); + + await test('the old always-visible button grid is gone (.export-label, .all-group)', () => { + assert(POPUP_DOM.querySelectorAll('.export-label').length === 0, '.export-label should have been removed'); + assert(POPUP_DOM.querySelectorAll('.all-group').length === 0, '.all-group should have been removed'); + }); + + await test('debug-mode buttons were left alone, outside the export menu', () => { + const menu = POPUP_DOM.getElementById('exportMenu'); + const debugGroup = POPUP_DOM.getElementById('debug-group'); + assert(debugGroup, '#debug-group missing'); + assert(!menu.contains(debugGroup), '#debug-group should not have been folded into the export menu'); + }); + + await test('popup.js maps every picker format to its hidden button element (FORMAT_TO_BTN)', () => { + const formats = ['md', 'pdf', 'html', 'json', 'docx', "'copy-html'", 'all', 'gist', 'notion']; + formats.forEach(f => { + assert(new RegExp(`${f}:\\s*\\w+Btn`).test(POPUP_JS), `FORMAT_TO_BTN appears to be missing an entry for ${f}`); + }); + // Copy MD/ZIP are deliberately NOT in this map — they're quick buttons now. + assert(!/'copy-md':\s*copyBtn/.test(POPUP_JS), "FORMAT_TO_BTN should no longer include 'copy-md'"); + assert(!/zip:\s*zipBtn/.test(POPUP_JS), 'FORMAT_TO_BTN should no longer include zip'); + }); + + await test('setLoading() mirrors state onto the Export button', () => { + assert(/exportGoBtn\.disabled = on/.test(POPUP_JS) && /exportGoBtn\.classList\.toggle\('loading', on\)/.test(POPUP_JS), + 'expected setLoading() to also toggle exportGoBtn state'); + }); + + await test('picking a menu row only updates the selection — it must not call the real button\'s .click()', () => { + const menuClickBlock = POPUP_JS.match(/el\.addEventListener\('click',\s*\(\)\s*=>\s*\{[\s\S]*?\}\);\s*\}\);/); + assert(menuClickBlock, "expected an el.addEventListener('click', ...) wiring block for menu rows"); + assert(/setSelectedFormat/.test(menuClickBlock[0]), 'expected the menu row handler to call setSelectedFormat(...)'); + assert(!/\.click\(\)/.test(menuClickBlock[0]), 'menu row click handler must not fire a real .click() — that must be the Export button\'s job only'); + }); + + await test('only the Export button\'s click handler proxies to FORMAT_TO_BTN[selectedFormat]', () => { + assert(/exportGoBtn\?\.addEventListener\('click',\s*\(\)\s*=>\s*\{\s*FORMAT_TO_BTN\[selectedFormat\]\?\.click\(\)/.test(POPUP_JS), + "expected exportGoBtn's click handler to call FORMAT_TO_BTN[selectedFormat]?.click()"); + }); + + await test('seeds the selected format from the last successful export, falling back to the Settings default', () => { + assert(/inkpour_last_export/.test(POPUP_JS) && /userSettings\.defaultFormat/.test(POPUP_JS), + 'expected the picker to read inkpour_last_export.format with a userSettings.defaultFormat fallback'); + }); + + await test('Gist/Notion visibility gating targets the menu rows, not the (always-hidden) real buttons', () => { + assert(/gistMenuOption.*userSettings\.githubToken/.test(POPUP_JS) || /userSettings\.githubToken.*gistMenuOption/.test(POPUP_JS), + 'expected the githubToken gate to show/hide #gistMenuOption'); + assert(/notionMenuOption.*userSettings\.notionToken/.test(POPUP_JS) || /userSettings\.notionToken.*notionMenuOption/.test(POPUP_JS), + 'expected the notionToken gate to show/hide #notionMenuOption'); + }); + + // ─── Toolbar icon — bigger supported-site signal ───────────────────────── + // The native OS badge corner is fixed-size by the browser and can't be + // made bigger, so the "you can export this page" signal moved into the + // icon bitmap itself: icons/icon-*-active.png bakes in a large green + // checkmark, swapped per-tab via background.js's updateBadge(). This + // guards against the files going missing (background.js would then call + // setIcon() with a broken path and silently fail — no error surfaces + // anywhere a developer would see it) and against the active/default sets + // accidentally pointing at the same file. + console.log('\nToolbar icon — bigger supported-site signal'); + + const BACKGROUND_JS = fs.readFileSync(path.resolve(__dirname, '../background.js'), 'utf8'); + const ICON_SIZES = [16, 32, 48, 128]; + + await test('background.js references an -active icon variant for every manifest icon size', () => { + ICON_SIZES.forEach(size => { + assert(BACKGROUND_JS.includes(`icons/icon-${size}-active.png`), + `background.js's ICON_ACTIVE map is missing icons/icon-${size}-active.png`); + }); + }); + + await test('every referenced -active icon file actually exists and is non-empty', () => { + ICON_SIZES.forEach(size => { + const p = path.resolve(__dirname, `../icons/icon-${size}-active.png`); + assert(fs.existsSync(p), `missing icons/icon-${size}-active.png`); + assert(fs.statSync(p).size > 0, `icons/icon-${size}-active.png is empty`); + }); + }); + + await test('active icon variant is a different file from the default (not a copy-paste no-op)', () => { + ICON_SIZES.forEach(size => { + const activeBytes = fs.readFileSync(path.resolve(__dirname, `../icons/icon-${size}-active.png`)); + const defaultBytes = fs.readFileSync(path.resolve(__dirname, `../icons/icon-${size}.png`)); + assert(!activeBytes.equals(defaultBytes), `icon-${size}-active.png is byte-identical to icon-${size}.png`); + }); + }); + + await test('active icon files are valid PNGs of the declared size', () => { + ICON_SIZES.forEach(size => { + const buf = fs.readFileSync(path.resolve(__dirname, `../icons/icon-${size}-active.png`)); + // PNG signature, then IHDR chunk: width/height are the first 8 bytes + // after the 4-byte length + 4-byte "IHDR" tag, i.e. offset 16/20. + assert(buf.readUInt32BE(0) === 0x89504e47, `icon-${size}-active.png missing PNG signature`); + const width = buf.readUInt32BE(16); + const height = buf.readUInt32BE(20); + assert(width === size && height === size, `icon-${size}-active.png is ${width}x${height}, expected ${size}x${size}`); + }); + }); + + await test('background.js clears leftover native badge text (no stale "ON" badge stacked on the new icon)', () => { + assert(/setBadgeText\(\s*\{\s*text:\s*''/.test(BACKGROUND_JS), + 'expected an explicit setBadgeText({text:\'\'...}) clearing any previously-set badge text'); + }); + + // Regression: reloading the extension while an already-open, already- + // focused tab sits on a supported page used to leave that tab's icon on + // its default (non-green) state — neither tabs.onUpdated (navigation + // only) nor tabs.onActivated (switching TO a tab) fire just because the + // extension itself reloaded, so nothing ever told that tab's icon to + // update. Fixed by syncing every open tab's icon on install/update/ + // reload and on browser startup. + await test('syncs every open tab\'s icon on install/update/reload (not just on navigate/switch)', () => { + assert(/onInstalled\.addListener\(\s*\(\)\s*=>\s*\{\s*syncAllTabIcons/.test(BACKGROUND_JS), + 'expected onInstalled to call syncAllTabIcons()'); + assert(/function syncAllTabIcons/.test(BACKGROUND_JS), 'expected a syncAllTabIcons() helper'); + assert(/tabs\.query\(\{\}\)/.test(BACKGROUND_JS), + 'expected syncAllTabIcons() to query all open tabs, not just the active one'); + }); + + await test('also syncs icons on browser startup (restored tabs from a previous session)', () => { + assert(/onStartup\?\.addListener\(\s*\(\)\s*=>\s*\{\s*syncAllTabIcons/.test(BACKGROUND_JS), + 'expected onStartup to also call syncAllTabIcons()'); + }); + + // ─── CHANGELOG.md — release notes source of truth (Batch 10 prep) ──────── + // CHANGELOG.md is the file Batch 10's future automated store-publishing + // workflow will read release-note text from for each tagged version. This + // guards against the two ways that could silently go stale: a version bump + // landing with no matching section, and an "Unreleased" section that's + // supposed to get renamed at release time being forgotten. + console.log('\nCHANGELOG.md — release notes source of truth'); + + const CHANGELOG_PATH = path.resolve(__dirname, '../CHANGELOG.md'); + const CHANGELOG = fs.readFileSync(CHANGELOG_PATH, 'utf8'); + const MANIFEST_FOR_CHANGELOG = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../manifest.json'), 'utf8')); + + await test('has an "Unreleased" section', () => { + assert(/^## \[Unreleased\]/m.test(CHANGELOG), 'no "## [Unreleased]" heading found'); + }); + + await test('has a section for the current manifest.json version', () => { + const heading = `## [${MANIFEST_FOR_CHANGELOG.version}]`; + assert(CHANGELOG.includes(heading), + `no "${heading}" section — every tagged release needs a matching CHANGELOG entry`); + }); + + await test('every "## [x.y.z...]" heading is either Unreleased or a real git tag', () => { + const { execSync } = require('child_process'); + let tags; + try { + tags = new Set( + execSync('git tag -l', { cwd: path.resolve(__dirname, '..') }) + .toString().split('\n').filter(Boolean).map(t => t.replace(/^v/, '')) + ); + } catch { + return; // not a git checkout (e.g. a source-only CI archive) — skip silently + } + const headings = [...CHANGELOG.matchAll(/^## \[([^\]]+)\]/gm)].map(m => m[1]); + const unknown = headings.filter(h => h !== 'Unreleased' && h !== MANIFEST_FOR_CHANGELOG.version && !tags.has(h)); + assert(unknown.length === 0, `CHANGELOG has headings with no matching git tag: ${unknown.join(', ')}`); + }); + // ─── Firefox AMO manifest validation ───────────────────────────────────── console.log('\nFirefox AMO manifest validation');