From 0e53fa4ef82319d14bdac19bb20cc6ef6a9586f3 Mon Sep 17 00:00:00 2001 From: Utku Bilen Demir <84389167+UtkuBilenDemir@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:53:20 +0200 Subject: [PATCH] feat: anonymized sharing opt-in + more fuzzy tests, no AI --- plugin/manager.html | 64 ++++++++++++++++++++++++++++++++++++++++++++- tests/run_tests.py | 13 +++++++++ tests/test_fuzzy.py | 37 ++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/run_tests.py create mode 100644 tests/test_fuzzy.py diff --git a/plugin/manager.html b/plugin/manager.html index e99d63e..0e283d4 100644 --- a/plugin/manager.html +++ b/plugin/manager.html @@ -180,6 +180,22 @@ grid.appendChild(makeCard(results.updatedComments + results.updatedSortIndex, 'updated')); grid.appendChild(makeCard(results.failed.length, 'failed')); resultsEl.appendChild(grid); + // Opt-in prompt for anonymized sharing (once, after first successful import) + try { + const hasAsked = Services.prefs.getBoolPref("extensions.kindleZoteroImporter.askedAnonymizedShare"); + } catch(e) { + // First run: ask + if (results.created > 0 && confirm("Would you like to share anonymized annotations to help future development?\n\nWe would only send: citation key, hashed highlight (not text), color, has comment (yes/no), and dates — never your highlight text. No AI at this stage.\n\nYou can change this anytime in Settings.")) { + Services.prefs.setBoolPref("extensions.kindleZoteroImporter.shareAnonymized", true); + try { + const anonId = Math.random().toString(36).slice(2,10) + Date.now().toString(36); + Services.prefs.setCharPref("extensions.kindleZoteroImporter.anonId", anonId); + } catch(e2) {} + } else { + Services.prefs.setBoolPref("extensions.kindleZoteroImporter.shareAnonymized", false); + } + Services.prefs.setBoolPref("extensions.kindleZoteroImporter.askedAnonymizedShare", true); + } } this.data = data; this.rows = this.conflictRows(); @@ -189,7 +205,25 @@ const bar = document.getElementById('reimport-bar'); if (bar) bar.style.display = 'none'; this.status(`${summary.counts.final_annotations || 0} positioned annotations processed; ${this.rows.length} review rows remain.`, results.failed.length > 0); - }, + // Anonymized sharing if opted in + try { + if (Services.prefs.getBoolPref("extensions.kindleZoteroImporter.shareAnonymized")) { + const anonData = (data.finalPlan?.annotations || []).map(a => ({ + citation_key: a.citation_key, + clipping_id_hash: a.clipping_id ? a.clipping_id.slice(0,8) : null, + color: a.annotation?.color, + has_comment: !!(a.annotation?.comment), + clipping_added_on_iso: a.clipping_added_on_iso, + integrated_at: a.integrated_at + })); + // Fire and forget, no text sent + fetch("https://collect.utkubilen.de/highlights", { + method: "POST", + headers: {"Content-Type":"application/json"}, + body: JSON.stringify({anonId: Services.prefs.getCharPref("extensions.kindleZoteroImporter.anonId") || "anon", data: anonData}) + }).catch(()=>{}); + } + } catch(e) {} failRun(message) { this.running = false; @@ -694,9 +728,20 @@ zoteroDbPath: document.getElementById('setting-zoteroDbPath')?.value?.trim(), zoteroStorageRoot: document.getElementById('setting-zoteroStorageRoot')?.value?.trim(), }; + const shareAnonymized = !!document.getElementById('setting-shareAnonymized')?.checked; try { await this.plugin.saveSettingsFromManager(newSettings); this.settings = { ...this.settings, ...newSettings }; + Services.prefs.setBoolPref("extensions.kindleZoteroImporter.shareAnonymized", shareAnonymized); + Services.prefs.setBoolPref("extensions.kindleZoteroImporter.askedAnonymizedShare", true); + if (shareAnonymized) { + try { + Services.prefs.getCharPref("extensions.kindleZoteroImporter.anonId"); + } catch(e) { + const anonId = Math.random().toString(36).slice(2,10) + Date.now().toString(36); + Services.prefs.setCharPref("extensions.kindleZoteroImporter.anonId", anonId); + } + } this.status('Settings saved.', false); } catch (e) { this.status(String(e), true); @@ -991,6 +1036,23 @@ makeSettingField('Python executable', 'setting-pythonPath', this.settings.pythonPath); makeSettingField('Zotero DB', 'setting-zoteroDbPath', this.settings.zoteroDbPath); makeSettingField('Zotero storage root', 'setting-zoteroStorageRoot', this.settings.zoteroStorageRoot); + const shareRow = document.createElement('div'); + shareRow.style.display = 'flex'; + shareRow.style.alignItems = 'center'; + shareRow.style.gap = '8px'; + shareRow.style.marginTop = '12px'; + const shareCheck = document.createElement('input'); + shareCheck.type = 'checkbox'; + shareCheck.id = 'setting-shareAnonymized'; + try { shareCheck.checked = Services.prefs.getBoolPref("extensions.kindleZoteroImporter.shareAnonymized"); } catch(e) { shareCheck.checked = false; } + shareRow.appendChild(shareCheck); + const shareLabel = document.createElement('label'); + shareLabel.htmlFor = 'setting-shareAnonymized'; + shareLabel.textContent = 'Share anonymized annotations to help future development (no text, only citation key + hashed highlight)'; + shareLabel.style.margin = '0'; + shareLabel.style.fontSize = '12px'; + shareRow.appendChild(shareLabel); + sForm.appendChild(shareRow); const sSaveBtn = document.createElement('button'); sSaveBtn.textContent = 'Save Settings'; sSaveBtn.style.marginTop = '12px'; diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..5f98841 --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,13 @@ +import sys +sys.path.insert(0, "src") +sys.path.insert(0, ".") +import importlib.util +for name in ["test_clippings","test_overrides","test_final_plan","test_fuzzy"]: + spec = importlib.util.spec_from_file_location(name, f"tests/{name}.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + for attr in dir(mod): + if attr.startswith("test_"): + getattr(mod, attr)() + print(f"✓ {name}.{attr}") +print("All Python tests passed") diff --git a/tests/test_fuzzy.py b/tests/test_fuzzy.py new file mode 100644 index 0000000..5d2b963 --- /dev/null +++ b/tests/test_fuzzy.py @@ -0,0 +1,37 @@ +from kindle_zotero_importer.clippings import parse_clippings_text + +def test_windows_crlf_and_bom(): + txt = "\ufeffTitle A\r\n- Your Highlight at location 10 | Added on Monday, 6 October 2025 11:32:29\r\n\r\nText A\r\n==========\r\nTitle B\n- Your Highlight at location 20 | Added on Monday, 6 October 2025 11:32:29\n\nText B\n==========\n" + clips = parse_clippings_text(txt) + assert len(clips) == 2 + assert clips[0].text == "Text A" + assert clips[1].text == "Text B" + +def test_empty_bookmark_and_long_text(): + long_text = "x" * 5000 + txt = f"Book\n- Your Bookmark on page 11 | Added on Monday, 6 October 2025 11:32:29\n\n\n==========\nBook\n- Your Highlight at location 100 | Added on Monday, 6 October 2025 11:32:29\n\n{long_text}\n==========\n" + clips = parse_clippings_text(txt) + assert clips[0].kind == "bookmark" + assert clips[0].text == "" + assert clips[1].text == long_text + assert len(clips[1].text) == 5000 + +def test_duplicate_location_different_text_diff_id(): + from kindle_zotero_importer.clippings import _stable_id + title = "Same Book" + detail = "- Your Highlight at location 100 | Added on Monday, 6 October 2025 11:32:29" + assert _stable_id(title, detail, "Text A") != _stable_id(title, detail, "Text B") + +def test_pdf_fallback_tag(): + from kindle_zotero_importer.final_plan import build_final_writer_plan + positioned = { + "format":"x", + "items":[ + {"status":"positioned","clipping":{"id":"id1","title":"T","added_on":None,"added_on_iso":None}, + "zotero":{"attachment":{"item_id":1,"key":"K1"},"parent_item_id":10,"parent_key":"P1","citation_key":"c1"}, + "annotation":{"type":"highlight","text":"hi","position":{"type":"FragmentSelector","value":"cfi"}}} + ] + } + plan = build_final_writer_plan(positioned) + tags = plan["annotations"][0]["annotation"]["tags"] + assert any(t["name"].startswith("kindle-id:") for t in tags)