From 757521088d6b52f8aa23f581adec79cfe31c3a51 Mon Sep 17 00:00:00 2001 From: "tronicum@qvest" Date: Fri, 17 Jul 2026 20:41:36 +0200 Subject: [PATCH 01/16] fix: add missing api.notion.com host permission; sync privacy docs and README for Notion + vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Notion export subagent added a fetch() to api.notion.com but never declared it in manifest.json's host_permissions (same class of drift as the earlier SUPPORTED_HOSTS/supported-sites.json issue this session) — would likely be blocked under Firefox's stricter MV3 permission enforcement and is inconsistent with how api.github.com is already explicitly listed. PRIVACY.md and README.md's Privacy/Integrations sections still only mentioned Gist + webhook, making the privacy policy inaccurate now that Notion (a new external endpoint) and direct-to-vault (new local file writes) both exist. Updated both docs and the permissions table. --- PRIVACY.md | 8 ++++++-- README.md | 4 +++- manifest.json | 3 ++- 3 files changed, 11 insertions(+), 4 deletions(-) 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/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": { From 4d99e5bad26a7db0fd6068d81c24695e968b5334 Mon Sep 17 00:00:00 2001 From: "tronicum@qvest" Date: Fri, 17 Jul 2026 21:02:26 +0200 Subject: [PATCH 02/16] fix: ChatGPT Canvas code blocks missing language tag (TODOs Batch 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-investigated on a real logged-in ChatGPT account. Canvas code blocks turned out to already extract clean code correctly (the existing 'pre' handler's querySelector('code') happens to reach through the CodeMirror markup) — the original TODO's assumption of a whole unhandled side-panel UI didn't hold up. The one real gap: the language name lives as plain text in a '.sticky' toolbar header (alongside Copy/Run buttons) inside the same
, which none of the three existing language-detection heuristics
catch, so every Canvas export shipped with no language tag on the fence.

Added a 4th heuristic, gated on an actual CodeMirror editor being present
(.cm-editor/.cm-content/#code-block-viewer) so it can't misfire on some
other platform's unrelated '.sticky' element.

Also confirmed real selectors for ChatGPT's history sidebar for Batch 8
(a[href^="/c/"], data-sidebarItem, scrollable nav container) — documented
in TODOs.md. Text-canvas variant and pagination/virtualization behavior at
scale remain unverified (free-tier canvas tool call misfired; account only
had ~28 conversations, too few to observe lazy-load).

4 new JSDOM tests, confirmed to fail pre-fix and pass post-fix. Full suite
267→271 passed, 0 failed, before and after.
---
 planning/TODOs.md | 67 ++++++++++++++++++++++++++++++++++++++------
 src/content.js    | 19 +++++++++++++
 test/run-jsdom.js | 71 +++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 148 insertions(+), 9 deletions(-)

diff --git a/planning/TODOs.md b/planning/TODOs.md
index 7048cb9..3c20a60 100644
--- a/planning/TODOs.md
+++ b/planning/TODOs.md
@@ -231,8 +231,42 @@ 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.
+- [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 `
+                
+              
+            
+          
+          
+
+
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}`); + }); + }); + // ── buildMarkdown (from src/utils.js) ──────────────────────────────────── await suite('buildMarkdown', async () => { const msgs = [ From 9ccd0d1942b56915fa98a0081c12b13709746688 Mon Sep 17 00:00:00 2001 From: "tronicum@qvest" Date: Fri, 17 Jul 2026 23:14:29 +0200 Subject: [PATCH 03/16] docs: investigate Claude Artifacts DOM (Batch 7) and Claude history sidebar (Batch 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-investigated against a real logged-in Claude account (two artifacts created: Python + JS). Claude Artifacts: confirmed this genuinely needs the side-panel handling the original TODO assumed (unlike ChatGPT Canvas, which turned out in-line). extractClaude()'s current artifactSuffix logic queries for code/pre inside .artifact-block-cell, but that only ever contains a title+filetype preview card with zero code inside — so Claude Artifacts exports currently ship with NO artifact content at all. The real code lives in a separate #wiggle-file-content panel outside any message turn, with line-number-gutter text baked into textContent (needs per-line stripping), and only the most-recently-opened artifact's content is ever mounted — extracting all artifacts in a conversation needs click-through per card (confirmed a bare el.click() doesn't trigger the swap; only a real synthetic mouse click does). Not fixed this session — genuinely multi-session work as originally scoped, unlike Canvas. Claude sidebar (Batch 8): confirmed a[href^="/chat/"] selector, and that naive textContent doubles the title (a hidden .sr-only span + a visible aria-hidden duplicate) — use .sr-only for a clean title. Found Claude's dedicated /recents page (full searchable list + native multi-select) as a better Batch 8 enumeration target than the sidebar preview. --- planning/TODOs.md | 64 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/planning/TODOs.md b/planning/TODOs.md index 3c20a60..a65453b 100644 --- a/planning/TODOs.md +++ b/planning/TODOs.md @@ -267,8 +267,51 @@ path is one self-contained click handler (settings.js:138–162) building one something to build extraction around — a real text-canvas document was never actually observed. Needs a retry (ideally on a paid plan) before concluding anything about that variant's DOM. -- [ ] **L** Claude Artifacts: extract as structured blocks alongside the chat, - not as plain code. Same caveat: live DOM inspection required first. +- [ ] **L → investigated live, still L, not implemented** Claude Artifacts — + investigated live 2026-07 against a real logged-in Claude account, creating + two artifacts (Python + JS) in one conversation. Unlike ChatGPT Canvas, this + one really does need the multi-session/side-panel handling the original + note assumed — confirmed structure: + - Each artifact shows as a small preview card in the chat column + (`.artifact-block-cell`, matched 2/2 as expected) with just a title + + filetype badge (e.g. "Reverse string · PY") — **no code inside it at all**. + This is exactly why the CURRENT `artifactSuffix` logic in `extractClaude()` + (`clone.querySelectorAll('.artifact-block-cell, [class*="artifact-block"]')` + then `artEl.querySelector('code, pre, .cm-content, ...')`) silently + extracts nothing today — that querySelector has nothing to find inside the + card. Confirmed live: Claude Artifacts exports currently ship with ZERO + artifact content, only whatever prose summary the model writes alongside + the card (e.g. "Here's a simple script that reverses a string..."). + - The actual code lives in a completely separate right-side panel, anchored + by a distinctive, likely-stable id: `#wiggle-file-content` (confirmed + outside any `[data-testid="user-message"]`/`[data-testid="assistant-message"]` + turn — `.closest()` on those returns nothing). Its `textContent` is clean + code but each line is prefixed with a line-number gutter baked into the + same text flow (`" 1 def reverse_string(s: str) -> str:\n 2 return..."`) + — needs a per-line strip (e.g. `/^\s*\d+\s?/` per line) before use. + - **Only one artifact's content is ever mounted in the DOM at a time** — + confirmed with 2 real artifacts open in one conversation: `.artifact-block-cell` + count was 2, but `#wiggle-file-content` count stayed 1, showing whichever + artifact was created/opened most recently. Getting ALL artifacts in a + multi-artifact conversation requires clicking each preview card in turn, + reading the panel after each click, same click-through requirement found + for NotebookLM citations — but proportionally far less disruptive here + (a conversation typically has a handful of artifacts, not up to 192). + - **Real implementation gotcha confirmed live**: a bare `cardEl.click()` via + injected JS did NOT swap the panel (tried it, panel didn't change) — only + a genuine synthetic mouse click (dispatched via the browser's real input + pipeline, not the DOM `.click()` method) actually triggered the swap. + A real fix will need to dispatch a proper `MouseEvent` sequence + (mousedown/mouseup/click, `bubbles: true`) rather than `el.click()`. + - Not attempted as a fix this session: this needs (a) the synthetic-click + mechanism above validated more rigorously, (b) correctly associating each + extracted artifact's content back to the message/turn that created it + (the panel is conversation-wide, not turn-scoped, so this needs tracking + which card belongs to which turn), and (c) testing across Claude's other + artifact types (React components, HTML, SVG, Mermaid, plain markdown) — + which likely render very differently inside `#wiggle-file-content` than + the plain-code case tested here. Genuinely multi-session work, matching + the original L estimate — unlike Canvas, this one didn't shrink. - [x] **M → investigated, not implemented** NotebookLM inline source citations — investigated live 2026-07 against a real 54-source notebook. `extractCitations()` already pulls the correct citation numbers from `button.citation-marker` @@ -372,6 +415,23 @@ path is one self-contained click handler (settings.js:138–162) building one but true pagination/virtualization behavior (what happens with hundreds of conversations) is still unverified — needs an account with much more history to actually observe. + + **Claude sidebar selectors — confirmed live 2026-07**: `a[href^="/chat/"]` + reliably finds every conversation link. Unlike ChatGPT, the visible/`textContent` + title is DOUBLED (e.g. `"Debugging old Raspberry Pi firmwareDebugging old + Raspberry Pi firmware"`) — confirmed why: each link contains both a + `.sr-only` span (screen-reader-only, full clean title) and a sibling + `aria-hidden="true"` `.block.truncate` span (the visually-truncated display + copy) with the same text, so naive `textContent` concatenates both. Use + `link.querySelector('.sr-only')?.textContent` for a clean single-instance + title instead. More importantly: Claude has a dedicated, separate + **`/recents` page** ("Chats" in the sidebar nav) with a full searchable/ + filterable list, distinct from the abbreviated sidebar preview — it even + ships its own native "Select chats" multi-select button already, and is a + much better enumeration target for Batch 8 than scraping the sidebar + (search, filter-by, and timestamps are all already there for free). Same + lazy-load caveat as ChatGPT: this account only has 7 conversations total, so + no pagination could be observed either way. - Realistic per-tab load timeout per platform (chatgpt/gemini/aistudio are already known to be slow lazy-loaders from the streaming-toast work). - How many conversations per run before it risks looking bot-like or From 22d140772e6a891717184126afa13d94e7c1ed93 Mon Sep 17 00:00:00 2001 From: "tronicum@qvest" Date: Fri, 17 Jul 2026 23:40:20 +0200 Subject: [PATCH 04/16] docs: correct ChatGPT sidebar lazy-load finding (Batch 8) Instant scrollTop jump doesn't trigger lazy-load; confirmed via bulk-create test that genuine incremental scroll + dispatched scroll events does (28 -> 56 conversations, stabilizing at true max scrollTop). Corrects the earlier 'inconclusive' note and documents the implementation implication for Batch 8 orchestration code. --- planning/TODOs.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/planning/TODOs.md b/planning/TODOs.md index a65453b..4040594 100644 --- a/planning/TODOs.md +++ b/planning/TODOs.md @@ -406,15 +406,23 @@ path is one self-contained click handler (settings.js:138–162) building one container is the `