From 4a5528c175252f9cfc5798c589b7d3f8b09319d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Basti=20Kn=C3=B6rr?= <106304099+erorplex@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:50:28 +0200 Subject: [PATCH 1/2] Add copy counters and thread notes - Every copy button shows how often this email or thread was copied with it; the tooltip adds the time of the last copy. Counters update across tabs. - Note button below the subject opens an autosaving note field for the thread; it reopens with the thread and is highlighted when a note exists. Keystrokes in the field don't reach Gmail's shortcuts. - Toolbar popup lists all notes (newest first) with search, link back to Gmail, delete and JSON export. - src/store.js keeps counters and notes in chrome.storage.local ("storage" permission), with tests for the pure parts. - Docs: README, README.de.md, PRIVACY, CHANGELOG. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + PRIVACY.md | 5 + README.de.md | 16 ++- README.md | 22 +++- _locales/de/messages.json | 11 +- _locales/en/messages.json | 11 +- manifest.json | 41 ++++++-- src/content.css | 61 +++++++++++ src/content.js | 207 +++++++++++++++++++++++++++++++++----- src/gmail.js | 12 +++ src/popup.css | 162 +++++++++++++++++++++++++++++ src/popup.html | 24 +++++ src/popup.js | 102 +++++++++++++++++++ src/store.js | 90 +++++++++++++++++ test/store.test.js | 34 +++++++ 15 files changed, 762 insertions(+), 39 deletions(-) create mode 100644 src/popup.css create mode 100644 src/popup.html create mode 100644 src/popup.js create mode 100644 src/store.js create mode 100644 test/store.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ee2bfd9..eab3737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and - Thread output with numbered messages and quoted reply history removed; forwarded emails are kept. - macOS helper (Swift, native messaging) that puts text and attachments on the clipboard as real files, quarantined like browser downloads. +- Copy counters on every copy button: how often an email or thread was copied that way, and when last. +- Notes per thread: autosaving note field below the subject that reopens with the thread; toolbar popup lists all + notes with search, link back to Gmail, delete and JSON export. Stored only locally. - German and English interface. ### Security diff --git a/PRIVACY.md b/PRIVACY.md index 375a803..ff63344 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -16,6 +16,10 @@ Paper Clipper for Gmail processes your email only on your own computer. a local process). The helper writes them to `~/Library/Caches/PaperClipper` and references them on the clipboard. The most recent copy stays there so that pasting keeps working; older copies are deleted the next time you copy with attachments (after one hour). `./uninstall.sh` removes the folder. +- Notes are stored in the browser's extension storage (`chrome.storage.local`) on this computer, together with the + subject and the Gmail link of the thread. Copy counters are a local record of which email or thread you copied + and when. Neither is synced or shared. Notes can be deleted and exported in the toolbar popup; counters are + deleted together with the extension. - Nothing is sent to any server. There are no analytics, no telemetry, no remote code and no third-party libraries. ## Permissions @@ -25,6 +29,7 @@ Paper Clipper for Gmail processes your email only on your own computer. | Access to `mail.google.com` (content script) | show the buttons and load the email you choose to copy | | `clipboardWrite` | put the text on the clipboard | | `nativeMessaging` | talk to the local macOS helper for attachments | +| `storage` | keep your notes and copy counters on this computer | ## Contact diff --git a/README.de.md b/README.de.md index 01f4b1a..6b909e0 100644 --- a/README.de.md +++ b/README.de.md @@ -1,7 +1,7 @@ # Paper Clipper für Gmail **Eine Gmail-Mail oder einen ganzen Verlauf mit einem Klick kopieren: Betreff, Absender, Empfänger, Datum und Text. -Auf Wunsch kommen die Anhänge als echte Dateien mit.** +Auf Wunsch kommen die Anhänge als echte Dateien mit. Zu jedem Verlauf kannst du eigene Notizen schreiben.** Mit ⌘V einfügen in Claude, ChatGPT, Slack, ein Ticket oder den Finder. Die PDFs kommen dabei als PDFs an, nicht als Textzeile. @@ -21,6 +21,10 @@ In Gmail erscheinen zwei Button-Leisten: | an jeder geöffneten Mail | **Mit Anhängen** | Text und jeder Anhang als Datei | | unter dem Betreff | **Verlauf kopieren** | alle Mails der Unterhaltung, nummeriert, ohne doppelte Zitate | | unter dem Betreff | **Verlauf + Anhänge** | der Verlauf und alle Anhänge, jeder nur einmal | +| unter dem Betreff | **Notiz** | öffnet deine Notiz zu diesem Verlauf (siehe [Notizen](#anleitung-notizen)) | + +Jeder Kopier-Button zählt, wie oft du diese Mail oder diesen Verlauf damit schon kopiert hast. Die Zahl steht am +Button, der Tooltip zeigt, wann zuletzt. So siehst du sofort, was du schon weitergegeben hast. Beispiel für **Kopieren**: @@ -97,6 +101,16 @@ Gmail neu laden und eine Mail öffnen. Die Buttons stehen unter dem Betreff und behält den vollen Text. - Kleine eingebettete Bilder unter 30 KB, meist Signatur-Logos, werden übersprungen. Eingefügte Screenshots kommen mit. +## Anleitung: Notizen + +- Unter dem Betreff auf **Notiz** klicken und schreiben. Die Notiz speichert sich beim Tippen von selbst. +- Öffnest du den Verlauf wieder, klappt die Notiz von selbst auf und der Button **Notiz** ist hervorgehoben. +- Text leeren löscht die Notiz. +- Ein Klick auf das Paper-Clipper-Symbol in der Chrome-Symbolleiste zeigt alle Notizen: neueste zuerst, mit Suche, + Link zurück zur Mail, Löschen und **Export** als JSON. +- Notizen und Zähler liegen nur in diesem Browser (`chrome.storage.local`). Sie werden nicht synchronisiert und beim + Entfernen der Erweiterung gelöscht. Vorher exportieren. + ## Aktualisieren ```bash diff --git a/README.md b/README.md index 866d1ae..79abdec 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![Platform: Chrome · macOS](https://img.shields.io/badge/platform-Chrome%20%C2%B7%20macOS-lightgrey) **Copy a Gmail email or a whole thread in one click: subject, sender, recipients, date and text. The attachments -can come along as real files.** +can come along as real files. Keep private notes on any thread.** Paste into Claude, ChatGPT, Slack, a ticket or the Finder with ⌘V. The PDFs arrive as PDFs, not as a line of text. @@ -24,6 +24,10 @@ Two button bars appear in Gmail: | every opened email | **With attachments** | text plus every attachment as a file | | below the subject | **Copy thread** | every email of the conversation, numbered, quoted history removed | | below the subject | **Thread + attachments** | the thread plus all attachments, each one once | +| below the subject | **Note** | opens your note on this thread (see [Notes](#notes)) | + +Each copy button counts how often you copied this email or thread with it. The number sits on the button and the +tooltip shows when you copied it last, so you see at a glance what you already passed on. Example of **Copy**: @@ -100,6 +104,16 @@ Click **With attachments**, switch to the Finder and press ⌘V: the attachments - Small images embedded in the text, under 30 KB and usually signature logos, are skipped. Pasted screenshots are kept. +## Notes + +- Click **Note** below the subject and type. The note saves itself while you type. +- The next time you open the thread, the note opens by itself and the **Note** button is highlighted. +- Clearing the text deletes the note. +- Click the Paper Clipper icon in the Chrome toolbar to see all notes: newest first, with search, a link back to + the email, delete, and **export** as JSON. +- Notes and counters are stored only in this browser (`chrome.storage.local`). They are not synced and are deleted + when you remove the extension. Export them first. + ## Updating ```bash @@ -150,7 +164,7 @@ Everything stays on your computer. No server, no analytics, no third-party code. ## Development ```bash -npm test # parser, formatting and version tests (Node ≥ 20, no dependencies) +npm test # parser, formatting, storage and version tests (Node ≥ 20, no dependencies) npm run build:helper # compile the macOS helper npm run test:helper # end-to-end helper test (macOS, overwrites the clipboard) npm run icons # render the icons @@ -160,8 +174,10 @@ npm run icons # render the icons ``` manifest.json Manifest V3 -src/content.js buttons, notifications, copy flow +src/content.js buttons, counters, notes, notifications, copy flow src/gmail.js everything Gmail-specific (DOM selectors, raw message URL) +src/store.js counters and notes in chrome.storage.local +src/popup.* toolbar popup with all notes src/mime.js MIME parser src/format.js text output, quote stripping, file list src/background.js relay to the macOS helper diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 5ce9c35..20b8360 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -1,4 +1,11 @@ { - "name": { "message": "Paper Clipper für Gmail" }, - "description": { "message": "Kopiert Gmail-Mails und ganze Verläufe mit einem Klick, auf Wunsch mit allen Anhängen als echte Dateien." } + "name": { + "message": "Paper Clipper für Gmail" + }, + "description": { + "message": "Kopiert Gmail-Mails und ganze Verläufe mit einem Klick, auf Wunsch mit allen Anhängen als echte Dateien." + }, + "actionTitle": { + "message": "Notizen von Paper Clipper" + } } diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 8e765c9..924a35b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1,4 +1,11 @@ { - "name": { "message": "Paper Clipper for Gmail" }, - "description": { "message": "Copy Gmail emails and whole threads in one click, optionally with every attachment as a real file." } + "name": { + "message": "Paper Clipper for Gmail" + }, + "description": { + "message": "Copy Gmail emails and whole threads in one click, optionally with every attachment as a real file." + }, + "actionTitle": { + "message": "Paper Clipper notes" + } } diff --git a/manifest.json b/manifest.json index 48ecf40..c51d3c2 100644 --- a/manifest.json +++ b/manifest.json @@ -6,15 +6,44 @@ "description": "__MSG_description__", "default_locale": "en", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnQMcZNi0VbggiDDt++3oD25hupu42yw+3mLcKLY3oGeZxUiyrcVb9t6b4EKGDHgHTaIuGImPp8CWgPZ6AO10V2lOHcLHcEgr1v8u70QRRluXkUbSK5iXfjW9hrXEoLKYGSaFeP/EF3bO9ZjalLNaqCyLZ18isB9DOyI7kZXe+wDFgklSJ/WgU5ezb4h6ifZRqHEoq+rJN50ZKrfjO0Rb1lpDvXyhLyHjIvmZTy7bdjYp1dXijw9JYkJ+7QqHyMclVann57e487NbPnS6jpIR7t7HztoG6KrpomfCo1w1Q++ebzzI6q4s48sgYpRrlIOGlk9RGe2SpzkvegZfRTvCFQIDAQAB", - "permissions": ["nativeMessaging", "clipboardWrite"], - "background": { "service_worker": "src/background.js" }, + "permissions": [ + "nativeMessaging", + "clipboardWrite", + "storage" + ], + "background": { + "service_worker": "src/background.js" + }, + "action": { + "default_title": "__MSG_actionTitle__", + "default_popup": "src/popup.html", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png" + } + }, "content_scripts": [ { - "matches": ["https://mail.google.com/*"], - "js": ["src/mime.js", "src/format.js", "src/gmail.js", "src/content.js"], - "css": ["src/content.css"], + "matches": [ + "https://mail.google.com/*" + ], + "js": [ + "src/mime.js", + "src/format.js", + "src/store.js", + "src/gmail.js", + "src/content.js" + ], + "css": [ + "src/content.css" + ], "run_at": "document_idle" } ], - "icons": { "16": "icons/icon16.png", "32": "icons/icon32.png", "48": "icons/icon48.png", "128": "icons/icon128.png" } + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } } diff --git a/src/content.css b/src/content.css index 11513f3..e7bb585 100644 --- a/src/content.css +++ b/src/content.css @@ -9,6 +9,10 @@ margin: 8px 0 4px; } +.paper-clipper-thread > .paper-clipper-bar { + margin: 0; +} + .paper-clipper-btn { display: inline-flex; align-items: center; @@ -41,6 +45,63 @@ flex: none; } +.paper-clipper-count { + min-width: 18px; + height: 18px; + margin-left: 2px; + padding: 0 5px; + box-sizing: border-box; + border-radius: 9px; + background: rgba(128, 128, 128, 0.2); + font-size: 11px; + line-height: 18px; + text-align: center; +} + +.paper-clipper-count[hidden] { + display: none; +} + +.paper-clipper-btn.is-active { + border-color: rgba(227, 116, 0, 0.7); + background: rgba(251, 188, 4, 0.18); +} + +.paper-clipper-note { + max-width: 760px; + margin: 8px 0 4px; +} + +.paper-clipper-note[hidden] { + display: none; +} + +.paper-clipper-note-field { + display: block; + box-sizing: border-box; + width: 100%; + min-height: 76px; + padding: 10px 12px; + border: 1px solid rgba(227, 116, 0, 0.45); + border-radius: 8px; + background: rgba(251, 188, 4, 0.12); + color: inherit; + font: 14px/1.45 'Google Sans', Roboto, Arial, sans-serif; + resize: vertical; +} + +.paper-clipper-note-field:focus { + outline: 2px solid #0b57d0; + outline-offset: -1px; +} + +.paper-clipper-note-status { + min-height: 16px; + margin-top: 4px; + font: 12px/16px 'Google Sans', Roboto, Arial, sans-serif; + opacity: 0.7; +} + .paper-clipper-busy .paper-clipper-btn { opacity: 0.5; cursor: progress; diff --git a/src/content.js b/src/content.js index 3436957..955c429 100644 --- a/src/content.js +++ b/src/content.js @@ -1,6 +1,7 @@ /* - * Adds the copy buttons to Gmail and runs the copy: fetch raw messages, format them, then put the - * text on the clipboard directly or hand text plus attachments to the Mac helper. + * Adds the copy buttons, copy counters and thread notes to Gmail and runs the copy: fetch raw + * messages, format them, then put the text on the clipboard directly or hand text plus attachments + * to the Mac helper. * No innerHTML anywhere: Gmail enforces Trusted Types. */ (function () { @@ -19,6 +20,13 @@ threadTitle: 'Alle Mails dieses Verlaufs kopieren', threadWithAttachments: 'Verlauf + Anhänge', threadWithAttachmentsTitle: 'Alle Mails des Verlaufs plus alle Anhänge als Dateien kopieren (Mac-Helfer)', + note: 'Notiz', + noteTitle: 'Eigene Notiz zu diesem Verlauf, nur lokal gespeichert', + notePlaceholder: 'Notiz zu diesem Verlauf … (wird automatisch gespeichert)', + noteSaving: 'Speichert …', + noteSaved: (when) => `Gespeichert · ${when}`, + noteDeleted: 'Notiz gelöscht', + copiedTimes: (n, when) => `Bereits ${n}× kopiert, zuletzt ${when}`, loading: (n) => (n === 1 ? 'Lade Mail …' : `Lade ${n} Nachrichten …`), mail: 'Mail', threadOf: (n) => `Verlauf (${n} ${n === 1 ? 'Nachricht' : 'Nachrichten'})`, @@ -28,6 +36,7 @@ noMessages: 'Keine Mail gefunden. Bitte Gmail neu laden.', helperMissing: 'Mac-Helfer fehlt: im Ordner der Erweiterung ./install.sh ausführen.', helperForbidden: 'Mac-Helfer kennt diese Erweiterung nicht: ./install.sh erneut ausführen.', + reload: 'Paper Clipper wurde aktualisiert. Bitte Gmail neu laden.', clipboardFailed: 'Zwischenablage nicht erreichbar. Bitte einmal in Gmail klicken und erneut versuchen.', failed: 'Kopieren fehlgeschlagen', } @@ -40,6 +49,13 @@ threadTitle: 'Copy every email in this thread', threadWithAttachments: 'Thread + attachments', threadWithAttachmentsTitle: 'Copy every email in this thread plus all attachments as files (Mac helper)', + note: 'Note', + noteTitle: 'Your own note on this thread, stored only locally', + notePlaceholder: 'Note on this thread … (saved automatically)', + noteSaving: 'Saving …', + noteSaved: (when) => `Saved · ${when}`, + noteDeleted: 'Note deleted', + copiedTimes: (n, when) => `Copied ${n}× so far, last ${when}`, loading: (n) => (n === 1 ? 'Loading email …' : `Loading ${n} messages …`), mail: 'Email', threadOf: (n) => `Thread (${n} ${n === 1 ? 'message' : 'messages'})`, @@ -49,6 +65,7 @@ noMessages: 'No email found. Please reload Gmail.', helperMissing: 'Mac helper missing: run ./install.sh in the extension folder.', helperForbidden: 'The Mac helper does not know this extension: run ./install.sh again.', + reload: 'Paper Clipper was updated. Please reload Gmail.', clipboardFailed: 'Clipboard not available. Click into Gmail once and try again.', failed: 'Copy failed', }; @@ -59,9 +76,14 @@ 'M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z', thread: 'M21 6h-2v9H6v2c0 .55.45 1 1 1h11l4 4V7c0-.55-.45-1-1-1zm-4 6V3c0-.55-.45-1-1-1H3c-.55 0-1 .45-1 1v14l4-4h10c.55 0 1-.45 1-1z', + note: 'M3 10h11v2H3zm0-2h11V6H3zm0 8h7v-2H3zm15.01-3.13.71-.71c.39-.39 1.02-.39 1.41 0l.71.71c.39.39.39 1.02 0 1.41l-.71.71-2.12-2.12zm-.71.71-5.3 5.3V21h2.12l5.3-5.3-2.12-2.12z', }; - // ---------- UI ---------- + function when(ts) { + return new Date(ts).toLocaleString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + } + + // ---------- UI building blocks ---------- function icon(path) { const ns = 'http://www.w3.org/2000/svg'; @@ -74,25 +96,51 @@ return svg; } - function button(label, title, iconPath, action) { + function button(label, title, iconPath, onClick) { const b = document.createElement('button'); b.type = 'button'; b.className = 'paper-clipper-btn'; b.title = title; + b.dataset.pcTitle = title; b.append(icon(iconPath), document.createTextNode(label)); // Keep Gmail from treating the click as "collapse message" or similar. b.addEventListener('mousedown', (e) => e.stopPropagation()); b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - run(action); + onClick(); }); return b; } - function bar(kind, buttons) { + // A copy button with a counter badge: how often this mail or thread was copied this way. + function copyButton(label, title, iconPath, target, mode, action) { + const b = button(label, title, iconPath, () => run(action)); + const count = document.createElement('span'); + count.className = 'paper-clipper-count'; + count.hidden = true; + b.append(count); + if (target) { + b.dataset.pcTarget = target; + b.dataset.pcMode = mode; + guard(PC.getStats(target)).then((stats) => stats && showStats(target, stats)); + } + return b; + } + + function showStats(target, stats) { + for (const b of document.querySelectorAll(`.paper-clipper-btn[data-pc-target="${CSS.escape(target)}"]`)) { + const n = (stats && stats[b.dataset.pcMode]) || 0; + const count = b.querySelector('.paper-clipper-count'); + count.textContent = String(n); + count.hidden = n === 0; + b.title = n ? `${b.dataset.pcTitle}\n${T.copiedTimes(n, when(stats.lastAt))}` : b.dataset.pcTitle; + } + } + + function bar(buttons) { const el = document.createElement('div'); - el.className = `paper-clipper-bar paper-clipper-${kind}`; + el.className = 'paper-clipper-bar'; el.append(...buttons); return el; } @@ -133,6 +181,7 @@ const msg = String((err && err.message) || err); if (/native messaging host not found|failed to start native messaging host/i.test(msg)) return T.helperMissing; if (/forbidden/i.test(msg)) return T.helperForbidden; + if (/context invalidated/i.test(msg)) return T.reload; if (err && err.userFacing) return msg; return `${T.failed}: ${msg}`; } @@ -141,26 +190,36 @@ return Object.assign(new Error(message), { userFacing: true }); } + // Counters and notes are extras: a storage failure (e.g. after an extension update) must not break copying. + async function guard(promise) { + try { + return await promise; + } catch (err) { + console.warn('[Paper Clipper]', err); + return null; + } + } + // ---------- Copy ---------- - async function copy(refs, { thread, withAttachments }) { + async function copy(refs, { thread, withAttachments, target }) { if (!refs.length) throw userError(T.noMessages); toast(T.loading(refs.length), { sticky: true }); const msgs = (await PC.fetchRawAll(refs)).map((raw) => PC.parseMessage(raw)); const text = thread ? PC.formatThread(msgs, { locale }) : PC.formatMessage(msgs[0], { locale }); const what = thread ? T.threadOf(msgs.length) : T.mail; - if (!withAttachments) { + let result; + const files = withAttachments ? PC.collectFiles(msgs, PC.textFileName(msgs[0].subject, thread, locale), text) : []; + if (files.length > 1) { + await helperCopy(text, files); + result = T.copiedFiles(what, files.length - 1); + } else { await writeClipboard(text); - return T.copied(what); + result = withAttachments ? T.noAttachments(what) : T.copied(what); } - const files = PC.collectFiles(msgs, PC.textFileName(msgs[0].subject, thread, locale), text); - if (files.length === 1) { - await writeClipboard(text); - return T.noAttachments(what); - } - await helperCopy(text, files); - return T.copiedFiles(what, files.length - 1); + if (target) await guard(PC.recordCopy(target, withAttachments ? 'attach' : 'copy')); + return result; } async function writeClipboard(text) { @@ -219,6 +278,66 @@ })(); } + // ---------- Notes ---------- + + function autoGrow(field) { + field.style.height = 'auto'; + field.style.height = `${field.scrollHeight + 2}px`; + } + + function notePanel(threadId, subject) { + const panel = document.createElement('div'); + panel.className = 'paper-clipper-note'; + panel.hidden = true; + const field = document.createElement('textarea'); + field.className = 'paper-clipper-note-field'; + field.placeholder = T.notePlaceholder; + field.rows = 3; + field.setAttribute('aria-label', T.note); + const status = document.createElement('div'); + status.className = 'paper-clipper-note-status'; + panel.append(field, status); + + // Gmail shortcuts (e.g. "#" deletes the thread) must not see what is typed here. + for (const type of ['keydown', 'keypress', 'keyup']) panel.addEventListener(type, (e) => e.stopPropagation()); + + let timer = null; + const save = async () => { + clearTimeout(timer); + timer = null; + try { + const subjectText = currentSubject(subject).textContent.trim(); + await PC.saveNote(threadId, { text: field.value, subject: subjectText, url: location.href }); + status.textContent = field.value.trim() ? T.noteSaved(when(Date.now())) : T.noteDeleted; + } catch (err) { + status.textContent = explain(err); + } + }; + field.addEventListener('input', () => { + autoGrow(field); + status.textContent = T.noteSaving; + clearTimeout(timer); + timer = setTimeout(save, 500); + }); + field.addEventListener('blur', () => timer && save()); + return panel; + } + + // Updates every note UI of a thread, e.g. after typing in another tab. Opens it when asked and a note exists. + function showNote(threadId, note, { open = false } = {}) { + for (const wrap of document.querySelectorAll(`.paper-clipper-thread[data-pc-thread="${CSS.escape(threadId)}"]`)) { + const panel = wrap.querySelector('.paper-clipper-note'); + const field = wrap.querySelector('.paper-clipper-note-field'); + wrap.querySelector('.paper-clipper-note-btn').classList.toggle('is-active', !!note); + if (document.activeElement !== field) { + field.value = note ? note.text : ''; + wrap.querySelector('.paper-clipper-note-status').textContent = note ? T.noteSaved(when(note.updatedAt)) : ''; + } + if (open && note) panel.hidden = false; + if (!panel.hidden) autoGrow(field); + } + } + // ---------- Buttons in Gmail ---------- function currentSubject(original) { @@ -226,21 +345,54 @@ } function threadBar(subject) { + const threadId = PC.threadId(subject); + const target = threadId && `t:${threadId}`; const refs = () => PC.threadMessageRefs(currentSubject(subject)); - return bar('thread', [ - button(T.thread, T.threadTitle, ICONS.thread, async () => copy(await refs(), { thread: true, withAttachments: false })), - button(T.threadWithAttachments, T.threadWithAttachmentsTitle, ICONS.attach, async () => - copy(await refs(), { thread: true, withAttachments: true }) + const wrap = document.createElement('div'); + wrap.className = 'paper-clipper-thread'; + const buttons = [ + copyButton(T.thread, T.threadTitle, ICONS.thread, target, 'copy', async () => + copy(await refs(), { thread: true, withAttachments: false, target }) ), - ]); + copyButton(T.threadWithAttachments, T.threadWithAttachmentsTitle, ICONS.attach, target, 'attach', async () => + copy(await refs(), { thread: true, withAttachments: true, target }) + ), + ]; + if (threadId) { + wrap.dataset.pcThread = threadId; + const panel = notePanel(threadId, subject); + const noteButton = button(T.note, T.noteTitle, ICONS.note, () => { + panel.hidden = !panel.hidden; + noteButton.setAttribute('aria-expanded', String(!panel.hidden)); + if (!panel.hidden) { + autoGrow(panel.firstChild); + panel.firstChild.focus(); + } + }); + noteButton.classList.add('paper-clipper-note-btn'); + noteButton.setAttribute('aria-expanded', 'false'); + buttons.push(noteButton); + wrap.append(bar(buttons), panel); + guard(PC.getNote(threadId)).then((note) => note && showNote(threadId, note, { open: true })); + } else { + wrap.append(bar(buttons)); + } + return wrap; } function messageBar(ref) { const refs = ref ? [ref] : []; - return bar('message', [ - button(T.copy, T.copyTitle, ICONS.copy, () => copy(refs, { thread: false, withAttachments: false })), - button(T.withAttachments, T.withAttachmentsTitle, ICONS.attach, () => copy(refs, { thread: false, withAttachments: true })), + const target = ref && `m:${ref.id || ref.perm}`; + const el = bar([ + copyButton(T.copy, T.copyTitle, ICONS.copy, target, 'copy', () => + copy(refs, { thread: false, withAttachments: false, target }) + ), + copyButton(T.withAttachments, T.withAttachmentsTitle, ICONS.attach, target, 'attach', () => + copy(refs, { thread: false, withAttachments: true, target }) + ), ]); + el.classList.add('paper-clipper-message'); + return el; } function scan() { @@ -253,6 +405,11 @@ } } + PC.onStoreChange((kind, id, value) => { + if (kind === 'stats') showStats(id, value); + else showNote(id, value); + }); + let scheduled = null; new MutationObserver(() => { if (scheduled) return; diff --git a/src/gmail.js b/src/gmail.js index 90c2341..ffc981a 100644 --- a/src/gmail.js +++ b/src/gmail.js @@ -46,6 +46,17 @@ return null; } + // Stable id of the conversation, used for thread counters and notes. + function threadId(subject) { + const legacy = subject.getAttribute('data-legacy-thread-id'); + if (legacy) return legacy; + const perm = subject.getAttribute('data-thread-perm-id'); + if (perm) return perm; + const container = threadContainer(subject); + const first = container && messageRefs(container)[0]; + return first ? first.id || first.perm : null; + } + function visibleSubjects() { return [...document.querySelectorAll(SUBJECT)].filter(isVisible); } @@ -135,6 +146,7 @@ Object.assign(exports, { isVisible, visibleSubjects, + threadId, expandedMessages, insertThreadBar, insertMessageBar, diff --git a/src/popup.css b/src/popup.css new file mode 100644 index 0000000..9ef33eb --- /dev/null +++ b/src/popup.css @@ -0,0 +1,162 @@ +:root { + color-scheme: light dark; + --bg: #fff; + --fg: #1f1f1f; + --muted: #5f6368; + --line: #e3e3e3; + --note: #fef7e0; + --accent: #0f766e; + --danger: #b3261e; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #202124; + --fg: #e8eaed; + --muted: #9aa0a6; + --line: #3c4043; + --note: #3a3526; + --accent: #5eead4; + --danger: #f2b8b5; + } +} + +* { + box-sizing: border-box; +} + +body { + width: 380px; + max-height: 560px; + margin: 0; + padding: 12px; + background: var(--bg); + color: var(--fg); + font: 13px/1.4 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; +} + +header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +h1 { + margin: 0; + font-size: 15px; + font-weight: 600; +} + +.count { + color: var(--muted); +} + +.icon-btn { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + margin-left: auto; + padding: 0; + border: 1px solid var(--line); + border-radius: 14px; + background: transparent; + color: var(--fg); + cursor: pointer; +} + +.icon-btn:disabled { + opacity: 0.4; + cursor: default; +} + +.icon-btn svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +input[type='search'] { + width: 100%; + margin-bottom: 10px; + padding: 7px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: transparent; + color: var(--fg); + font: inherit; +} + +ul { + margin: 0; + padding: 0; + list-style: none; +} + +li { + margin-bottom: 8px; + padding: 10px 12px; + border-radius: 8px; + background: var(--note); +} + +.row { + display: flex; + align-items: baseline; + gap: 8px; +} + +.subject { + flex: 1; + overflow: hidden; + color: var(--accent); + font-weight: 600; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.subject:hover { + text-decoration: underline; +} + +time { + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.text { + display: -webkit-box; + margin: 4px 0 0; + overflow: hidden; + white-space: pre-wrap; + word-break: break-word; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; +} + +.delete { + padding: 0; + border: 0; + background: none; + color: var(--danger); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +#empty, +footer { + color: var(--muted); +} + +#empty { + margin: 16px 4px; +} + +footer { + margin-top: 6px; + font-size: 11px; +} diff --git a/src/popup.html b/src/popup.html new file mode 100644 index 0000000..74b2827 --- /dev/null +++ b/src/popup.html @@ -0,0 +1,24 @@ + + + + + Paper Clipper + + + +
+ +

+ + +
+ + + + + + + + diff --git a/src/popup.js b/src/popup.js new file mode 100644 index 0000000..f249cf8 --- /dev/null +++ b/src/popup.js @@ -0,0 +1,102 @@ +// Toolbar popup: every thread note, newest first, with search, delete and JSON export. +(async function () { + 'use strict'; + + const PC = globalThis.PaperClipper; + const locale = navigator.language || 'de-DE'; + const T = locale.toLowerCase().startsWith('de') + ? { + title: 'Notizen', + search: 'Notizen durchsuchen …', + empty: 'Noch keine Notizen. Öffne einen Verlauf in Gmail und klicke auf „Notiz“.', + noMatch: 'Keine Notiz passt zur Suche.', + noSubject: '(kein Betreff)', + remove: 'Löschen', + confirmRemove: (subject) => `Notiz zu „${subject}“ löschen?`, + export: 'Alle Notizen als JSON exportieren', + footer: 'Notizen liegen nur in diesem Browser. Beim Entfernen der Erweiterung werden sie gelöscht, vorher exportieren.', + } + : { + title: 'Notes', + search: 'Search notes …', + empty: 'No notes yet. Open a thread in Gmail and click “Note”.', + noMatch: 'No note matches your search.', + noSubject: '(no subject)', + remove: 'Delete', + confirmRemove: (subject) => `Delete the note on “${subject}”?`, + export: 'Export all notes as JSON', + footer: 'Notes live only in this browser and are deleted with the extension. Export them first.', + }; + + const $ = (id) => document.getElementById(id); + const search = $('search'); + const exportButton = $('export'); + $('title').textContent = T.title; + search.placeholder = T.search; + exportButton.title = T.export; + exportButton.setAttribute('aria-label', T.export); + $('footer').textContent = T.footer; + + let notes = await PC.allNotes(); + render(); + search.addEventListener('input', render); + PC.onStoreChange(async (kind) => { + if (kind !== 'note') return; + notes = await PC.allNotes(); + render(); + }); + exportButton.addEventListener('click', () => { + const blob = new Blob([JSON.stringify(notes, null, 2)], { type: 'application/json' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `paper-clipper-notes-${new Date().toISOString().slice(0, 10)}.json`; + link.click(); + setTimeout(() => URL.revokeObjectURL(link.href), 1000); + }); + + function render() { + const shown = PC.filterNotes(notes, search.value); + $('list').replaceChildren(...shown.map(item)); + $('count').textContent = notes.length ? String(notes.length) : ''; + $('empty').hidden = shown.length > 0; + $('empty').textContent = notes.length ? T.noMatch : T.empty; + exportButton.disabled = notes.length === 0; + } + + function item(note) { + const li = document.createElement('li'); + const row = document.createElement('div'); + row.className = 'row'; + const subject = document.createElement('a'); + subject.className = 'subject'; + subject.textContent = note.subject || T.noSubject; + // Only links back into Gmail; the url comes from storage, not from a page. + if (/^https:\/\/mail\.google\.com\//.test(note.url || '')) { + subject.href = note.url; + subject.target = '_blank'; + subject.rel = 'noopener'; + } + const time = document.createElement('time'); + time.dateTime = new Date(note.updatedAt).toISOString(); + time.textContent = new Date(note.updatedAt).toLocaleString(locale, { + day: '2-digit', + month: '2-digit', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'delete'; + remove.textContent = T.remove; + remove.addEventListener('click', async () => { + if (confirm(T.confirmRemove(note.subject || T.noSubject))) await PC.deleteNote(note.id); + }); + row.append(subject, time, remove); + const text = document.createElement('p'); + text.className = 'text'; + text.textContent = note.text; + li.append(row, text); + return li; + } +})(); diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..e42cc1d --- /dev/null +++ b/src/store.js @@ -0,0 +1,90 @@ +/* + * Copy counters and thread notes in chrome.storage.local. Nothing is synced or sent anywhere. + * stats:m: / stats:t: -> { copy, attach, lastAt } + * note: -> { text, subject, url, updatedAt } + * Runs in the content script and the popup (exports to globalThis.PaperClipper) and under Node for tests. + */ +(function (exports) { + 'use strict'; + + const STATS = 'stats:'; + const NOTE = 'note:'; + + function bumpStats(prev, mode, now) { + const stats = { copy: 0, attach: 0, lastAt: 0, ...(prev || {}) }; + stats[mode] += 1; + stats.lastAt = now; + return stats; + } + + function listNotes(items) { + return Object.entries(items) + .filter(([key, note]) => key.startsWith(NOTE) && note && note.text) + .map(([key, note]) => ({ id: key.slice(NOTE.length), ...note })) + .sort((a, b) => b.updatedAt - a.updatedAt); + } + + function filterNotes(notes, query) { + const q = query.trim().toLowerCase(); + if (!q) return notes; + return notes.filter((n) => `${n.subject}\n${n.text}`.toLowerCase().includes(q)); + } + + const area = () => chrome.storage.local; + + async function getItem(key) { + return (await area().get(key))[key] || null; + } + + function getStats(target) { + return getItem(STATS + target); + } + + async function recordCopy(target, mode) { + const key = STATS + target; + await area().set({ [key]: bumpStats(await getItem(key), mode, Date.now()) }); + } + + function getNote(threadId) { + return getItem(NOTE + threadId); + } + + // An empty note is deleted, so clearing the field is all it takes. + function saveNote(threadId, { text, subject, url }) { + const key = NOTE + threadId; + if (!text.trim()) return area().remove(key); + return area().set({ [key]: { text, subject, url, updatedAt: Date.now() } }); + } + + function deleteNote(threadId) { + return area().remove(NOTE + threadId); + } + + async function allNotes() { + return listNotes(await area().get(null)); + } + + // Calls fn(kind, id, value) for every changed counter ('stats') or note ('note'), across tabs. + function onStoreChange(fn) { + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'local') return; + for (const [key, { newValue }] of Object.entries(changes)) { + if (key.startsWith(STATS)) fn('stats', key.slice(STATS.length), newValue || null); + else if (key.startsWith(NOTE)) fn('note', key.slice(NOTE.length), newValue || null); + } + }); + } + + Object.assign(exports, { + bumpStats, + listNotes, + filterNotes, + getStats, + recordCopy, + getNote, + saveNote, + deleteNote, + allNotes, + onStoreChange, + }); +})(typeof module !== 'undefined' ? module.exports : (globalThis.PaperClipper = globalThis.PaperClipper || {})); diff --git a/test/store.test.js b/test/store.test.js new file mode 100644 index 0000000..29aa78c --- /dev/null +++ b/test/store.test.js @@ -0,0 +1,34 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { bumpStats, listNotes, filterNotes } = require('../src/store.js'); + +test('bumpStats counts per mode and remembers the last copy', () => { + const first = bumpStats(undefined, 'copy', 1000); + assert.deepEqual(first, { copy: 1, attach: 0, lastAt: 1000 }); + const second = bumpStats(first, 'attach', 2000); + assert.deepEqual(second, { copy: 1, attach: 1, lastAt: 2000 }); + assert.deepEqual(first, { copy: 1, attach: 0, lastAt: 1000 }, 'input stays untouched'); +}); + +test('listNotes returns notes newest first and ignores other keys and empty notes', () => { + const items = { + 'note:a': { text: 'alt', subject: 'A', url: 'u1', updatedAt: 1 }, + 'note:b': { text: 'neu', subject: 'B', url: 'u2', updatedAt: 5 }, + 'note:c': { text: '', subject: 'C', url: 'u3', updatedAt: 9 }, + 'stats:m:1': { copy: 3, attach: 0, lastAt: 7 }, + }; + assert.deepEqual( + listNotes(items).map((n) => n.id), + ['b', 'a'] + ); +}); + +test('filterNotes searches subject and text, case-insensitive', () => { + const notes = [ + { id: 'a', subject: 'Angebot Q4', text: 'Rückruf Montag' }, + { id: 'b', subject: 'Rechnung', text: 'bezahlt' }, + ]; + assert.deepEqual(filterNotes(notes, 'q4').map((n) => n.id), ['a']); + assert.deepEqual(filterNotes(notes, 'BEZAHLT').map((n) => n.id), ['b']); + assert.equal(filterNotes(notes, ' ').length, 2); +}); From e10d7b852316261c9588ea2a26b908b4dff64678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Basti=20Kn=C3=B6rr?= <106304099+erorplex@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:59:59 +0200 Subject: [PATCH 2/2] Address review findings for notes and counters - Note field follows changes from the popup or other tabs whenever it has no unsaved input (was: skipped while focused, so a deleted note came back). - Subject and link of a note are captured while its thread is on screen, so a late save never stores another thread's subject or URL, and never throws. - Pending note saves are flushed on pagehide / tab hidden. - Counters keep the time of the last copy per button (copyAt / attachAt). - The thread bar is replaced when Gmail reuses the header for another thread or the thread id only appears later. - aria-expanded follows the auto-opened note; own error text for notes. - Popup export uses a data URL, which survives the popup closing for a "Save as" dialog. - PRIVACY.md: notes include subject and Gmail link; counters are a local record of what was copied when. Co-Authored-By: Claude Opus 5.5 --- src/content.js | 55 ++++++++++++++++++++++++++++++++++++---------- src/popup.js | 5 ++--- src/store.js | 6 ++--- test/store.test.js | 10 ++++----- 4 files changed, 53 insertions(+), 23 deletions(-) diff --git a/src/content.js b/src/content.js index 955c429..c7d15ef 100644 --- a/src/content.js +++ b/src/content.js @@ -26,6 +26,7 @@ noteSaving: 'Speichert …', noteSaved: (when) => `Gespeichert · ${when}`, noteDeleted: 'Notiz gelöscht', + noteFailed: 'Notiz nicht gespeichert', copiedTimes: (n, when) => `Bereits ${n}× kopiert, zuletzt ${when}`, loading: (n) => (n === 1 ? 'Lade Mail …' : `Lade ${n} Nachrichten …`), mail: 'Mail', @@ -55,6 +56,7 @@ noteSaving: 'Saving …', noteSaved: (when) => `Saved · ${when}`, noteDeleted: 'Note deleted', + noteFailed: 'Note not saved', copiedTimes: (n, when) => `Copied ${n}× so far, last ${when}`, loading: (n) => (n === 1 ? 'Loading email …' : `Loading ${n} messages …`), mail: 'Email', @@ -130,11 +132,12 @@ function showStats(target, stats) { for (const b of document.querySelectorAll(`.paper-clipper-btn[data-pc-target="${CSS.escape(target)}"]`)) { - const n = (stats && stats[b.dataset.pcMode]) || 0; + const mode = b.dataset.pcMode; + const n = (stats && stats[mode]) || 0; const count = b.querySelector('.paper-clipper-count'); count.textContent = String(n); count.hidden = n === 0; - b.title = n ? `${b.dataset.pcTitle}\n${T.copiedTimes(n, when(stats.lastAt))}` : b.dataset.pcTitle; + b.title = n ? `${b.dataset.pcTitle}\n${T.copiedTimes(n, when(stats[`${mode}At`]))}` : b.dataset.pcTitle; } } @@ -285,7 +288,16 @@ field.style.height = `${field.scrollHeight + 2}px`; } + // Pending note saves, flushed when the tab is hidden or closed before the debounce fires. + const pendingSaves = new Set(); + const flushNotes = () => pendingSaves.forEach((save) => save()); + window.addEventListener('pagehide', flushNotes); + document.addEventListener('visibilitychange', () => document.visibilityState === 'hidden' && flushNotes()); + function notePanel(threadId, subject) { + // Remembered now, while this thread is on screen; Gmail may show another thread when the save runs. + let subjectText = subject.textContent.trim(); + let url = location.href; const panel = document.createElement('div'); panel.className = 'paper-clipper-note'; panel.hidden = true; @@ -305,35 +317,49 @@ const save = async () => { clearTimeout(timer); timer = null; + pendingSaves.delete(save); + if (subject.isConnected && PC.isVisible(subject)) { + subjectText = subject.textContent.trim(); + url = location.href; + } + const text = field.value; try { - const subjectText = currentSubject(subject).textContent.trim(); - await PC.saveNote(threadId, { text: field.value, subject: subjectText, url: location.href }); - status.textContent = field.value.trim() ? T.noteSaved(when(Date.now())) : T.noteDeleted; + await PC.saveNote(threadId, { text, subject: subjectText, url }); + if (!timer) delete field.dataset.pcDirty; // no newer input while saving + status.textContent = text.trim() ? T.noteSaved(when(Date.now())) : T.noteDeleted; } catch (err) { - status.textContent = explain(err); + status.textContent = /context invalidated/i.test(String(err && err.message)) ? T.reload : `${T.noteFailed}: ${err.message || err}`; } }; field.addEventListener('input', () => { autoGrow(field); + field.dataset.pcDirty = '1'; status.textContent = T.noteSaving; clearTimeout(timer); timer = setTimeout(save, 500); + pendingSaves.add(save); }); field.addEventListener('blur', () => timer && save()); return panel; } - // Updates every note UI of a thread, e.g. after typing in another tab. Opens it when asked and a note exists. + // Updates every note UI of a thread, e.g. after a change in another tab or in the popup. Text the + // user has typed but not saved yet is never overwritten. Opens the panel when asked and a note exists. function showNote(threadId, note, { open = false } = {}) { for (const wrap of document.querySelectorAll(`.paper-clipper-thread[data-pc-thread="${CSS.escape(threadId)}"]`)) { const panel = wrap.querySelector('.paper-clipper-note'); const field = wrap.querySelector('.paper-clipper-note-field'); - wrap.querySelector('.paper-clipper-note-btn').classList.toggle('is-active', !!note); - if (document.activeElement !== field) { - field.value = note ? note.text : ''; + const noteButton = wrap.querySelector('.paper-clipper-note-btn'); + noteButton.classList.toggle('is-active', !!note); + if (!field.dataset.pcDirty) { + const text = note ? note.text : ''; + if (field.value !== text) field.value = text; wrap.querySelector('.paper-clipper-note-status').textContent = note ? T.noteSaved(when(note.updatedAt)) : ''; } - if (open && note) panel.hidden = false; + if (open && note) { + panel.hidden = false; + noteButton.setAttribute('aria-expanded', 'true'); + } if (!panel.hidden) autoGrow(field); } } @@ -398,7 +424,12 @@ function scan() { for (const subject of PC.visibleSubjects()) { const next = subject.parentElement.nextElementSibling; - if (!(next && next.classList.contains('paper-clipper-thread'))) PC.insertThreadBar(subject, threadBar(subject)); + if (next && next.classList.contains('paper-clipper-thread')) { + // Gmail may reuse the header for another thread, or the id may only appear later. + if (next.dataset.pcThread === (PC.threadId(subject) || undefined)) continue; + next.remove(); + } + PC.insertThreadBar(subject, threadBar(subject)); } for (const { element, body, ref } of PC.expandedMessages()) { if (!element.querySelector('.paper-clipper-message')) PC.insertMessageBar(body, messageBar(ref)); diff --git a/src/popup.js b/src/popup.js index f249cf8..9f04fd9 100644 --- a/src/popup.js +++ b/src/popup.js @@ -46,12 +46,11 @@ render(); }); exportButton.addEventListener('click', () => { - const blob = new Blob([JSON.stringify(notes, null, 2)], { type: 'application/json' }); + // A data URL, unlike a blob URL, stays valid when the popup closes for a "Save as" dialog. const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); + link.href = `data:application/json;charset=utf-8,${encodeURIComponent(JSON.stringify(notes, null, 2))}`; link.download = `paper-clipper-notes-${new Date().toISOString().slice(0, 10)}.json`; link.click(); - setTimeout(() => URL.revokeObjectURL(link.href), 1000); }); function render() { diff --git a/src/store.js b/src/store.js index e42cc1d..30a58f4 100644 --- a/src/store.js +++ b/src/store.js @@ -1,6 +1,6 @@ /* * Copy counters and thread notes in chrome.storage.local. Nothing is synced or sent anywhere. - * stats:m: / stats:t: -> { copy, attach, lastAt } + * stats:m: / stats:t: -> { copy, attach, copyAt, attachAt } * note: -> { text, subject, url, updatedAt } * Runs in the content script and the popup (exports to globalThis.PaperClipper) and under Node for tests. */ @@ -11,9 +11,9 @@ const NOTE = 'note:'; function bumpStats(prev, mode, now) { - const stats = { copy: 0, attach: 0, lastAt: 0, ...(prev || {}) }; + const stats = { copy: 0, attach: 0, copyAt: 0, attachAt: 0, ...(prev || {}) }; stats[mode] += 1; - stats.lastAt = now; + stats[`${mode}At`] = now; return stats; } diff --git a/test/store.test.js b/test/store.test.js index 29aa78c..869a800 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -2,12 +2,12 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { bumpStats, listNotes, filterNotes } = require('../src/store.js'); -test('bumpStats counts per mode and remembers the last copy', () => { +test('bumpStats counts per mode and remembers the last copy per mode', () => { const first = bumpStats(undefined, 'copy', 1000); - assert.deepEqual(first, { copy: 1, attach: 0, lastAt: 1000 }); + assert.deepEqual(first, { copy: 1, attach: 0, copyAt: 1000, attachAt: 0 }); const second = bumpStats(first, 'attach', 2000); - assert.deepEqual(second, { copy: 1, attach: 1, lastAt: 2000 }); - assert.deepEqual(first, { copy: 1, attach: 0, lastAt: 1000 }, 'input stays untouched'); + assert.deepEqual(second, { copy: 1, attach: 1, copyAt: 1000, attachAt: 2000 }); + assert.deepEqual(first, { copy: 1, attach: 0, copyAt: 1000, attachAt: 0 }, 'input stays untouched'); }); test('listNotes returns notes newest first and ignores other keys and empty notes', () => { @@ -15,7 +15,7 @@ test('listNotes returns notes newest first and ignores other keys and empty note 'note:a': { text: 'alt', subject: 'A', url: 'u1', updatedAt: 1 }, 'note:b': { text: 'neu', subject: 'B', url: 'u2', updatedAt: 5 }, 'note:c': { text: '', subject: 'C', url: 'u3', updatedAt: 9 }, - 'stats:m:1': { copy: 3, attach: 0, lastAt: 7 }, + 'stats:m:1': { copy: 3, attach: 0, copyAt: 7, attachAt: 0 }, }; assert.deepEqual( listNotes(items).map((n) => n.id),