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)