0.6.4: incremental id-based import, Mappings/Integrated, black iA Duo, resizable tables
- id-based incremental (new_ids - prev_integrated_ids) with --full flag, kindle-id tag, deletions handling - Mappings tab with delete to return to Conflicts, sorted by updated_at newest first - Integrated tab with Added On / Integrated dates and Citekey - black over white iA Writer Duo theme, candidate-list per-row Use buttons, resizable/sortable tables - Settings editable + save, Artifacts Open/Reveal, Re-import bar with last file reuse - Fix override single-field validation and ignore cleanup
This commit is contained in:
parent
f5368a28cf
commit
bfe54e3953
16 changed files with 3501 additions and 715 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -9,3 +9,9 @@ __pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.venv/
|
.venv/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
.maschine/
|
||||||
|
dist/
|
||||||
|
plugin-summary.json
|
||||||
|
plugin-progress.json
|
||||||
|
plugin-config.json
|
||||||
|
kindle-import-manager.html
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -100,3 +100,17 @@ PDF positioning uses Poppler tools (`pdftotext`, `pdftohtml`, `pdfinfo`). If a P
|
||||||
## Safety Rule
|
## Safety Rule
|
||||||
|
|
||||||
Do not write directly to `zotero.sqlite`. Use read-only SQLite access for indexing and Zotero's JavaScript API for writes.
|
Do not write directly to `zotero.sqlite`. Use read-only SQLite access for indexing and Zotero's JavaScript API for writes.
|
||||||
|
|
||||||
|
## Zotero Plugin Wrapper
|
||||||
|
|
||||||
|
The `plugin/` directory contains a hybrid Zotero plugin with one user-facing Tools action. It lets the user choose `My Clippings.txt`, runs the importer with visible progress, and writes positioned annotations through Zotero's native APIs. Unresolved cases are reported after the import; intermediate JSON artifacts are implementation details.
|
||||||
|
|
||||||
|
Build the plugin package:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python scripts/build_plugin.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Install `dist/kindle-zotero-importer.xpi` in Zotero via `Tools > Plugins`. Open `Tools > Kindle Zotero Importer...`, choose `My Clippings.txt`, and keep the manager open to see the current stage, percentage, elapsed time, and final annotation counts.
|
||||||
|
|
||||||
|
The XPI manifest targets Zotero `6.999` through `10.0.*`.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,42 @@
|
||||||
{
|
{
|
||||||
"format": "kindle-zotero-importer.match-overrides.v1",
|
"format": "kindle-zotero-importer.match-overrides.v1",
|
||||||
"overrides": [
|
"overrides": [
|
||||||
|
{
|
||||||
|
"clipping_title": "(Springer Series in Statistics) Trevor Hastie, Robert Tibshirani, Jerome Friedman - The elements of statistical learning Data mining, inference & predi",
|
||||||
|
"resolution": {
|
||||||
|
"ignore": true
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"status": "ambiguous",
|
||||||
|
"clipping_count": 2
|
||||||
|
},
|
||||||
|
"created_at": "2026-09-16T12:39:19.713Z",
|
||||||
|
"updated_at": "2026-09-16T12:39:19.713Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clipping_title": "(Grundstudium Mathematik) Herbert Amann, Joachim Escher - Analysis 1-Birkhauser Basel (2006)",
|
||||||
|
"resolution": {
|
||||||
|
"ignore": true
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"status": "ambiguous",
|
||||||
|
"clipping_count": 3
|
||||||
|
},
|
||||||
|
"created_at": "2026-09-16T12:39:05.426Z",
|
||||||
|
"updated_at": "2026-09-16T12:39:08.496Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clipping_title": "The Communist Hypothesis (Alain Badiou)",
|
||||||
|
"resolution": {
|
||||||
|
"citation_key": "badiou2015"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"status": "epub-text-not-found",
|
||||||
|
"clipping_count": 1
|
||||||
|
},
|
||||||
|
"created_at": "2026-09-16T12:20:45.512Z",
|
||||||
|
"updated_at": "2026-09-16T12:20:45.512Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"clipping_title": "WilliamS.Burroughs-Nakedlunch-GrovePress(2001) (Utku)",
|
"clipping_title": "WilliamS.Burroughs-Nakedlunch-GrovePress(2001) (Utku)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
|
|
@ -9,13 +45,26 @@
|
||||||
"review": {
|
"review": {
|
||||||
"status": "matched",
|
"status": "matched",
|
||||||
"clipping_count": 74
|
"clipping_count": 74
|
||||||
}
|
},
|
||||||
|
"created_at": "2026-09-16T12:19:40.753Z",
|
||||||
|
"updated_at": "2026-09-16T12:19:40.753Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clipping_title": "On the Mode of Existence of Technical Objects (Univocal)",
|
||||||
|
"resolution": {
|
||||||
|
"ignore": true
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"status": "ambiguous",
|
||||||
|
"clipping_count": 16
|
||||||
|
},
|
||||||
|
"created_at": "2026-09-16T12:19:16.038Z",
|
||||||
|
"updated_at": "2026-09-16T12:19:16.038Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"clipping_title": "(Kapitalismus und Schizophrenie 2) Guattari, Deleuze - Tausend Plateaus, Mille Plateaux-Les Editions de Minuit (1980)",
|
"clipping_title": "(Kapitalismus und Schizophrenie 2) Guattari, Deleuze - Tausend Plateaus, Mille Plateaux-Les Editions de Minuit (1980)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -25,8 +74,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "[Penguin Classics] Marcus Aurelius, Martin Hammond, Diskin Clay - Meditations (2006, Penguin Classics) - libgen.li",
|
"clipping_title": "[Penguin Classics] Marcus Aurelius, Martin Hammond, Diskin Clay - Meditations (2006, Penguin Classics) - libgen.li",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -36,8 +84,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "APuZ_Postdemokratie.2011",
|
"clipping_title": "APuZ_Postdemokratie.2011",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -47,8 +94,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Arendt_2017_",
|
"clipping_title": "Arendt_2017_",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -58,8 +104,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Chantal Mouffe - Hegemonie und radikale Demokratie.-Passagen Verlag Ges.M.B.H (2000)",
|
"clipping_title": "Chantal Mouffe - Hegemonie und radikale Demokratie.-Passagen Verlag Ges.M.B.H (2000)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -69,8 +114,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Deleuze and Guattaris",
|
"clipping_title": "Deleuze and Guattaris",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -80,8 +124,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Deleuze, Bartleby; or, the Formula",
|
"clipping_title": "Deleuze, Bartleby; or, the Formula",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -91,8 +134,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Deleuze, Guattari- A Thousand Plateaus - Bobo",
|
"clipping_title": "Deleuze, Guattari- A Thousand Plateaus - Bobo",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -102,8 +144,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Dewey_2008_",
|
"clipping_title": "Dewey_2008_",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -113,8 +154,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "DRdZ 01 - Die Suche nach dem Auge der Welt (Robert Jordan)",
|
"clipping_title": "DRdZ 01 - Die Suche nach dem Auge der Welt (Robert Jordan)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -124,8 +164,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Ernesto Laclau - On Populist Reason-Verso (2005)",
|
"clipping_title": "Ernesto Laclau - On Populist Reason-Verso (2005)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -135,8 +174,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Exhalation (Ted Chiang)",
|
"clipping_title": "Exhalation (Ted Chiang)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -146,8 +184,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Fire & Blood (A Song of Ice and Fire) (George R. R. Martin)",
|
"clipping_title": "Fire & Blood (A Song of Ice and Fire) (George R. R. Martin)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -157,7 +194,6 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Galloway_2004_Protocol",
|
"clipping_title": "Galloway_2004_Protocol",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
|
||||||
"citation_key": "galloway_2004"
|
"citation_key": "galloway_2004"
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
|
|
@ -165,11 +201,20 @@
|
||||||
"clipping_count": 14
|
"clipping_count": 14
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"clipping_title": "gilbert-simondon-on-the-mode-of-existence-of-technical-objects",
|
||||||
|
"resolution": {
|
||||||
|
"citation_key": "simondon2017a"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"status": "matched",
|
||||||
|
"clipping_count": 28
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"clipping_title": "Gregs Tagebuch 13 - Eiskalt erwischt by Kinney Jeff, Schmidt Dietmar (z-lib.org)",
|
"clipping_title": "Gregs Tagebuch 13 - Eiskalt erwischt by Kinney Jeff, Schmidt Dietmar (z-lib.org)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -179,8 +224,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Hardt_Negri_2017_",
|
"clipping_title": "Hardt_Negri_2017_",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -190,8 +234,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Howaldt_Schwarz_2016_SI-DRIVE",
|
"clipping_title": "Howaldt_Schwarz_2016_SI-DRIVE",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -201,8 +244,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Latzer Just (2020)",
|
"clipping_title": "Latzer Just (2020)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -212,8 +254,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Lem, Stanislaw - Solaris (Stanisław Lem)",
|
"clipping_title": "Lem, Stanislaw - Solaris (Stanisław Lem)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -223,8 +264,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "luhmann1981",
|
"clipping_title": "luhmann1981",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -234,8 +274,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "manifestly_haraway_----_a_cyborg_manifesto_science_technology_and_socialist-feminism_in_the_...",
|
"clipping_title": "manifestly_haraway_----_a_cyborg_manifesto_science_technology_and_socialist-feminism_in_the_...",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -245,8 +284,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Marcus Aurelius (Emperor of Rome), Martin Hammond, Diskin Clay - Meditations (2006, Penguin Books) - libgen.li",
|
"clipping_title": "Marcus Aurelius (Emperor of Rome), Martin Hammond, Diskin Clay - Meditations (2006, Penguin Books) - libgen.li",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -256,8 +294,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Martyr! -- Akbar, Kaveh -- null, null, 2024 -- RANDOM HOUSE US -- 9780593802359 -- 930ede856e2a2efb28505e3c4e99e222 -- Anna’s Archive (Kaveh Akbar)",
|
"clipping_title": "Martyr! -- Akbar, Kaveh -- null, null, 2024 -- RANDOM HOUSE US -- 9780593802359 -- 930ede856e2a2efb28505e3c4e99e222 -- Anna’s Archive (Kaveh Akbar)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -267,8 +304,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "mew_band03",
|
"clipping_title": "mew_band03",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -278,8 +314,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "MiloradPaviç_-_ hazar_sözlügü",
|
"clipping_title": "MiloradPaviç_-_ hazar_sözlügü",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -289,8 +324,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "On Populist Reason (Laclau, Ernesto)",
|
"clipping_title": "On Populist Reason (Laclau, Ernesto)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -300,8 +334,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "paul-lafargue-the-right-to-be-lazy (Paul Lafargue)",
|
"clipping_title": "paul-lafargue-the-right-to-be-lazy (Paul Lafargue)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -311,8 +344,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Quantitative Marxism - Paul Dunne Ed_",
|
"clipping_title": "Quantitative Marxism - Paul Dunne Ed_",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -322,8 +354,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "radikale demokratie comtesse, nonhoff etc",
|
"clipping_title": "radikale demokratie comtesse, nonhoff etc",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -333,8 +364,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Rupert präsentiert: Ein echt wildes Abenteuer (Ruperts Tagebuch) (German Edition) (Kinney, Jeff)",
|
"clipping_title": "Rupert präsentiert: Ein echt wildes Abenteuer (Ruperts Tagebuch) (German Edition) (Kinney, Jeff)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -344,19 +374,27 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "The City & the City - China Mieville",
|
"clipping_title": "The City & the City - China Mieville",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
"clipping_count": 8
|
"clipping_count": 8
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"clipping_title": "The philosophy of Simondon_ between technology and individuation (Kirkpatrick, Graeme)",
|
||||||
|
"resolution": {
|
||||||
|
"citation_key": "chabot2013"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"status": "epub-text-not-found",
|
||||||
|
"clipping_count": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"clipping_title": "The Republic (Plato)",
|
"clipping_title": "The Republic (Plato)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -366,8 +404,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "The Satanic Verses_ A Novel (Salman Rushdie)",
|
"clipping_title": "The Satanic Verses_ A Novel (Salman Rushdie)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -377,8 +414,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "The Way of Kings (Sanderson, Brandon)",
|
"clipping_title": "The Way of Kings (Sanderson, Brandon)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -388,8 +424,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Your Clippings",
|
"clipping_title": "Your Clippings",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -399,8 +434,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Your P2K Articles (2024-12-17) (P2K)",
|
"clipping_title": "Your P2K Articles (2024-12-17) (P2K)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
@ -410,8 +444,7 @@
|
||||||
{
|
{
|
||||||
"clipping_title": "Your P2K Articles (2024-12-31) (P2K)",
|
"clipping_title": "Your P2K Articles (2024-12-31) (P2K)",
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"ignore": true,
|
"ignore": true
|
||||||
"citation_key": ""
|
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"status": "unmatched",
|
"status": "unmatched",
|
||||||
|
|
|
||||||
25
plugin/README.md
Normal file
25
plugin/README.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Kindle Zotero Importer Zotero Plugin
|
||||||
|
|
||||||
|
This is a hybrid Zotero plugin wrapper around the current Python importer. Zotero owns the user interface and writes native annotations through Zotero APIs; the Python project still performs parsing, matching, planning, EPUB positioning, and PDF positioning.
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
1. Install `kindle-zotero-importer.xpi` in Zotero through `Tools > Plugins`.
|
||||||
|
2. Open `Tools > Kindle Zotero Importer...`.
|
||||||
|
3. Select the updated Kindle `My Clippings.txt` in the manager.
|
||||||
|
4. Follow the current stage, percentage, and elapsed time in the manager.
|
||||||
|
5. Review created, existing, updated, failed, and unresolved counts when it completes.
|
||||||
|
|
||||||
|
The plugin uses `match-overrides.json` from the project directory as the persistent source of title mappings and ignored titles. Generated JSON artifacts are still written to the project directory for audit and debugging.
|
||||||
|
|
||||||
|
## Build XPI
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python scripts/build_plugin.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The output is `dist/kindle-zotero-importer.xpi`.
|
||||||
|
|
||||||
|
The plugin manifest targets Zotero `6.999` through `10.0.*`.
|
||||||
1275
plugin/bootstrap.js
vendored
Normal file
1275
plugin/bootstrap.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
1199
plugin/manager.html
Normal file
1199
plugin/manager.html
Normal file
File diff suppressed because it is too large
Load diff
16
plugin/manifest.json
Normal file
16
plugin/manifest.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 2,
|
||||||
|
"name": "Kindle Zotero Importer",
|
||||||
|
"version": "0.6.4",
|
||||||
|
"description": "Import Kindle My Clippings.txt highlights and notes into Zotero as native annotations.",
|
||||||
|
"homepage_url": "https://github.com/UtkuBilenDemir/kindle-zotero_importer",
|
||||||
|
"author": "Utku Bilen Demir",
|
||||||
|
"applications": {
|
||||||
|
"zotero": {
|
||||||
|
"id": "kindlezoteroimporter@utkubilen.de",
|
||||||
|
"update_url": "https://github.com/UtkuBilenDemir/kindle-zotero_importer/releases/latest/download/updates.json",
|
||||||
|
"strict_min_version": "6.999",
|
||||||
|
"strict_max_version": "10.0.*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
plugin/prefs.js
Normal file
4
plugin/prefs.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
pref("extensions.kindleZoteroImporter.projectDir", "/Users/ubd/Library/Mobile Documents/iCloud~md~obsidian/Documents/rhizome/06_projects/UTI/kindle-zotero-importer");
|
||||||
|
pref("extensions.kindleZoteroImporter.pythonPath", "/opt/homebrew/bin/python3");
|
||||||
|
pref("extensions.kindleZoteroImporter.zoteroDbPath", "/Users/ubd/Zotero/zotero.sqlite");
|
||||||
|
pref("extensions.kindleZoteroImporter.zoteroStorageRoot", "/Users/ubd/Zotero/storage");
|
||||||
17
plugin_runner.py
Normal file
17
plugin_runner.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
SRC = ROOT / "src"
|
||||||
|
|
||||||
|
if str(SRC) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
|
from kindle_zotero_importer.cli import main # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
62
scripts/build_canary_plugin.py
Normal file
62
scripts/build_canary_plugin.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DIST_DIR = ROOT / "dist"
|
||||||
|
OUTPUT = DIST_DIR / "kindle-zotero-importer-canary.xpi"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
DIST_DIR.mkdir(exist_ok=True)
|
||||||
|
if OUTPUT.exists():
|
||||||
|
OUTPUT.unlink()
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"manifest_version": 2,
|
||||||
|
"name": "Kindle Zotero Importer Canary",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Minimal install test for Kindle Zotero Importer.",
|
||||||
|
"homepage_url": "https://github.com/UtkuBilenDemir/kindle-zotero_importer",
|
||||||
|
"author": "Utku Bilen Demir",
|
||||||
|
"applications": {
|
||||||
|
"zotero": {
|
||||||
|
"id": "kindlezoteroimportercanary@utkubilen.de",
|
||||||
|
"update_url": "https://github.com/UtkuBilenDemir/kindle-zotero_importer/releases/latest/download/updates-canary.json",
|
||||||
|
"strict_min_version": "6.999",
|
||||||
|
"strict_max_version": "9.*",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
bootstrap = """
|
||||||
|
function install(data, reason) {}
|
||||||
|
function uninstall(data, reason) {}
|
||||||
|
async function startup(data, reason) {
|
||||||
|
await Zotero.initializationPromise;
|
||||||
|
Zotero.debug('Kindle Zotero Importer Canary started');
|
||||||
|
}
|
||||||
|
function shutdown(data, reason) {}
|
||||||
|
""".lstrip()
|
||||||
|
|
||||||
|
prefs = 'pref("extensions.kindleZoteroImporterCanary.enabled", true);\n'
|
||||||
|
|
||||||
|
with zipfile.ZipFile(OUTPUT, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for name, content in [
|
||||||
|
("bootstrap.js", bootstrap),
|
||||||
|
("manifest.json", json.dumps(manifest, indent=2) + "\n"),
|
||||||
|
("prefs.js", prefs),
|
||||||
|
]:
|
||||||
|
info = zipfile.ZipInfo(name)
|
||||||
|
info.create_system = 3
|
||||||
|
info.external_attr = 0o644 << 16
|
||||||
|
archive.writestr(info, content, zipfile.ZIP_DEFLATED)
|
||||||
|
|
||||||
|
print(f"Built {OUTPUT}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
74
scripts/build_plugin.py
Normal file
74
scripts/build_plugin.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PLUGIN_DIR = ROOT / "plugin"
|
||||||
|
DIST_DIR = ROOT / "dist"
|
||||||
|
OUTPUT = DIST_DIR / "kindle-zotero-importer.xpi"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
DIST_DIR.mkdir(exist_ok=True)
|
||||||
|
if OUTPUT.exists():
|
||||||
|
OUTPUT.unlink()
|
||||||
|
|
||||||
|
manifest_path = PLUGIN_DIR / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
zotero_app = manifest.get("applications", {}).get("zotero", {})
|
||||||
|
required_manifest_fields = [
|
||||||
|
"manifest_version",
|
||||||
|
"name",
|
||||||
|
"version",
|
||||||
|
"description",
|
||||||
|
]
|
||||||
|
missing_manifest_fields = [
|
||||||
|
field for field in required_manifest_fields if not manifest.get(field)
|
||||||
|
]
|
||||||
|
missing_zotero_fields = [
|
||||||
|
field
|
||||||
|
for field in ["id", "strict_min_version", "strict_max_version"]
|
||||||
|
if not zotero_app.get(field)
|
||||||
|
]
|
||||||
|
missing_files = [
|
||||||
|
str(path.relative_to(PLUGIN_DIR))
|
||||||
|
for path in [PLUGIN_DIR / "manifest.json", PLUGIN_DIR / "bootstrap.js"]
|
||||||
|
if not path.exists()
|
||||||
|
]
|
||||||
|
if missing_manifest_fields or missing_zotero_fields or missing_files:
|
||||||
|
problems = []
|
||||||
|
if missing_manifest_fields:
|
||||||
|
problems.append(f"manifest fields: {', '.join(missing_manifest_fields)}")
|
||||||
|
if missing_zotero_fields:
|
||||||
|
problems.append(
|
||||||
|
f"applications.zotero fields: {', '.join(missing_zotero_fields)}"
|
||||||
|
)
|
||||||
|
if missing_files:
|
||||||
|
problems.append(f"files: {', '.join(missing_files)}")
|
||||||
|
raise SystemExit("Invalid plugin package; missing " + "; ".join(problems))
|
||||||
|
|
||||||
|
with zipfile.ZipFile(OUTPUT, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for path in PLUGIN_DIR.rglob("*"):
|
||||||
|
if path.is_file():
|
||||||
|
archive_name = str(path.relative_to(PLUGIN_DIR))
|
||||||
|
info = zipfile.ZipInfo(archive_name)
|
||||||
|
info.create_system = 3
|
||||||
|
info.external_attr = 0o644 << 16
|
||||||
|
archive.writestr(info, path.read_bytes(), zipfile.ZIP_DEFLATED)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(OUTPUT) as archive:
|
||||||
|
names = set(archive.namelist())
|
||||||
|
if "manifest.json" not in names or "bootstrap.js" not in names:
|
||||||
|
raise SystemExit(
|
||||||
|
"Invalid XPI: manifest.json and bootstrap.js must be at archive root"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Built {OUTPUT}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from .clippings import clippings_to_jsonable, load_clippings
|
from .clippings import clippings_to_jsonable, load_clippings
|
||||||
from .epub_position import add_epub_positions
|
from .epub_position import add_epub_positions
|
||||||
|
|
@ -141,6 +142,45 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
"--output", "-o", help="Write Markdown to this file instead of stdout"
|
"--output", "-o", help="Write Markdown to this file instead of stdout"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
run_parser = subparsers.add_parser(
|
||||||
|
"run", help="Run the full importer pipeline for a Kindle My Clippings.txt file"
|
||||||
|
)
|
||||||
|
run_parser.add_argument("clippings_file", help="Path to Kindle My Clippings.txt")
|
||||||
|
run_parser.add_argument(
|
||||||
|
"--workdir",
|
||||||
|
default=".",
|
||||||
|
help="Directory for generated importer artifacts",
|
||||||
|
)
|
||||||
|
run_parser.add_argument(
|
||||||
|
"--db", default="/Users/ubd/Zotero/zotero.sqlite", help="Path to zotero.sqlite"
|
||||||
|
)
|
||||||
|
run_parser.add_argument(
|
||||||
|
"--storage-root",
|
||||||
|
default="/Users/ubd/Zotero/storage",
|
||||||
|
help="Path to Zotero storage directory for storage: attachments",
|
||||||
|
)
|
||||||
|
run_parser.add_argument(
|
||||||
|
"--overrides",
|
||||||
|
default="match-overrides.json",
|
||||||
|
help="Path to reusable match overrides JSON, relative to workdir unless absolute",
|
||||||
|
)
|
||||||
|
run_parser.add_argument(
|
||||||
|
"--summary-output",
|
||||||
|
help="Write pipeline summary JSON to this path instead of stdout",
|
||||||
|
)
|
||||||
|
run_parser.add_argument(
|
||||||
|
"--progress-output",
|
||||||
|
help="Write machine-readable pipeline progress JSON to this path",
|
||||||
|
)
|
||||||
|
run_parser.add_argument(
|
||||||
|
"--pretty", action="store_true", help="Pretty-print JSON artifacts"
|
||||||
|
)
|
||||||
|
run_parser.add_argument(
|
||||||
|
"--full",
|
||||||
|
action="store_true",
|
||||||
|
help="Force full re-import from scratch, ignoring incremental state (processes all clippings)",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if args.command == "parse":
|
if args.command == "parse":
|
||||||
|
|
@ -275,6 +315,236 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
sys.stdout.write(text)
|
sys.stdout.write(text)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.command == "run":
|
||||||
|
workdir = Path(args.workdir).expanduser().resolve()
|
||||||
|
workdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
progress_path = (
|
||||||
|
Path(args.progress_output).expanduser().resolve()
|
||||||
|
if args.progress_output
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
def report_progress(percent: int, stage: str, detail: str) -> None:
|
||||||
|
if not progress_path:
|
||||||
|
return
|
||||||
|
temporary_path = progress_path.with_suffix(progress_path.suffix + ".tmp")
|
||||||
|
temporary_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{"percent": percent, "stage": stage, "detail": detail},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
temporary_path.replace(progress_path)
|
||||||
|
|
||||||
|
def artifact(name: str) -> Path:
|
||||||
|
return workdir / name
|
||||||
|
|
||||||
|
overrides_path = Path(args.overrides).expanduser()
|
||||||
|
if not overrides_path.is_absolute():
|
||||||
|
overrides_path = workdir / overrides_path
|
||||||
|
|
||||||
|
report_progress(2, "Reading clippings", "Parsing My Clippings.txt")
|
||||||
|
new_clippings_list = load_clippings(args.clippings_file)
|
||||||
|
new_clippings = clippings_to_jsonable(new_clippings_list)
|
||||||
|
new_ids = {c["id"] for c in new_clippings.get("clippings", [])}
|
||||||
|
|
||||||
|
# Incremental state: previous clippings and previous integrated ids
|
||||||
|
is_incremental = not args.full
|
||||||
|
prev_clippings_ids: set[str] = set()
|
||||||
|
prev_integrated_ids: set[str] = set()
|
||||||
|
prev_final_plan = None
|
||||||
|
if is_incremental:
|
||||||
|
prev_clippings_path = artifact("clippings.json")
|
||||||
|
prev_final_path = artifact("import-plan.final.json")
|
||||||
|
if prev_clippings_path.exists():
|
||||||
|
try:
|
||||||
|
prev_clippings_ids = {c["id"] for c in load_json(str(prev_clippings_path)).get("clippings", [])}
|
||||||
|
except Exception:
|
||||||
|
prev_clippings_ids = set()
|
||||||
|
if prev_final_path.exists():
|
||||||
|
try:
|
||||||
|
prev_final_plan = load_json(str(prev_final_path))
|
||||||
|
prev_integrated_ids = {a.get("clipping_id") for a in prev_final_plan.get("annotations", []) if a.get("clipping_id")}
|
||||||
|
except Exception:
|
||||||
|
prev_final_plan = None
|
||||||
|
prev_integrated_ids = set()
|
||||||
|
# If no previous state, fall back to full
|
||||||
|
if not prev_clippings_ids and not prev_integrated_ids:
|
||||||
|
is_incremental = False
|
||||||
|
|
||||||
|
if is_incremental:
|
||||||
|
# New or previously not integrated (skipped) -> need to (re)process
|
||||||
|
to_process_ids = set(new_ids - prev_integrated_ids)
|
||||||
|
# Deletions: previously integrated but now absent (removed or changed text)
|
||||||
|
deletions_ids = sorted(prev_integrated_ids - new_ids)
|
||||||
|
# Also re-queue any previously integrated clippings whose title now has an override
|
||||||
|
# (mapping may have changed, need to move annotation). We need overrides dict for this.
|
||||||
|
# Load overrides early for this check (already loaded later, but do here for incremental decision)
|
||||||
|
# We will reload overrides here if not yet loaded
|
||||||
|
try:
|
||||||
|
_ov_for_inc = load_overrides(load_json(str(overrides_path))) if overrides_path.exists() else {}
|
||||||
|
except Exception:
|
||||||
|
_ov_for_inc = {}
|
||||||
|
for c in new_clippings_list:
|
||||||
|
if c.id in prev_integrated_ids and c.title in _ov_for_inc:
|
||||||
|
to_process_ids.add(c.id)
|
||||||
|
# Also handle deleted overrides: if a previously integrated title's override was deleted,
|
||||||
|
# its annotations should be deleted. Detect via previous final plan's titles vs current overrides.
|
||||||
|
# For incremental, we don't have previous overrides, so we handle deletions via the main deletions list
|
||||||
|
# (IDs no longer present). For deleted overrides where ID still present but now unmatched,
|
||||||
|
# the match report will make them not positioned, and they will be in to_process but then skipped;
|
||||||
|
# we need to ensure they are deleted. We do this by checking if a previously integrated clipping's
|
||||||
|
# title is now not in overrides and would be unmatched -> treat as to_delete.
|
||||||
|
# Simplify: any prev_integrated clipping whose title is not in current new match's "matched" will be
|
||||||
|
# handled as not in to_process_ids? Actually it is still in new_ids, but we re-queued it above only if title in overrides.
|
||||||
|
# For now, rely on full re-import for deleted-override cleanup.
|
||||||
|
# Build filtered clippings for this delta run
|
||||||
|
filtered_list = [c for c in new_clippings_list if c.id in to_process_ids]
|
||||||
|
# If nothing to do and no deletions, still need to report but skip expensive steps
|
||||||
|
if not filtered_list and not deletions_ids:
|
||||||
|
report_progress(15, "Matching titles", "No new highlights — incremental skip")
|
||||||
|
# Still need to generate artifacts for consistency (empty delta)
|
||||||
|
zotero_index = build_zotero_index(args.db, args.storage_root)
|
||||||
|
overrides = None
|
||||||
|
if overrides_path.exists():
|
||||||
|
overrides = load_overrides(load_json(str(overrides_path)))
|
||||||
|
clippings = new_clippings
|
||||||
|
matches = build_match_report(clippings, zotero_index, overrides)
|
||||||
|
override_skeleton = generate_override_skeleton(matches)
|
||||||
|
plan = {"format": "kindle-zotero-importer.import-plan.v1", "items": []}
|
||||||
|
positioned_plan = {"format": plan["format"], "items": []}
|
||||||
|
final_plan = {"format": FINAL_FORMAT, "source_format": positioned_plan.get("format"), "annotation_count": 0, "skipped_counts": {}, "annotations": [], "deletions": [], "is_incremental": True, "incremental_stats": {"new_clippings": len(new_ids), "prev_integrated": len(prev_integrated_ids), "to_process": 0, "deletions": 0}}
|
||||||
|
mismatch_review = build_mismatch_review(positioned_plan, matches)
|
||||||
|
# Will write artifacts below with empty delta
|
||||||
|
else:
|
||||||
|
report_progress(8, "Indexing Zotero", "Reading library items and attachments (incremental)")
|
||||||
|
zotero_index = build_zotero_index(args.db, args.storage_root)
|
||||||
|
overrides = None
|
||||||
|
if overrides_path.exists():
|
||||||
|
overrides = load_overrides(load_json(str(overrides_path)))
|
||||||
|
# Only the delta clippings go through matching/positioning
|
||||||
|
delta_clippings = clippings_to_jsonable(filtered_list)
|
||||||
|
report_progress(15, "Matching titles", f"Matching {len(filtered_list)} new/pending highlights (incremental)")
|
||||||
|
matches = build_match_report(delta_clippings, zotero_index, overrides)
|
||||||
|
override_skeleton = generate_override_skeleton(matches)
|
||||||
|
# For artifact completeness, also build full matches for summary? Use delta matches for now
|
||||||
|
# But we need full matches for review? Keep delta
|
||||||
|
report_progress(22, "Building plan", "Selecting attachments (incremental)")
|
||||||
|
plan = build_import_plan(delta_clippings, zotero_index, matches)
|
||||||
|
report_progress(30, "Positioning EPUB highlights", "Locating highlights in EPUB files (incremental)")
|
||||||
|
epub_plan = add_epub_positions(plan)
|
||||||
|
report_progress(55, "Positioning PDF highlights", "Locating highlights in PDF files (incremental)")
|
||||||
|
positioned_plan = add_pdf_positions(epub_plan)
|
||||||
|
report_progress(90, "Finalizing", "Preparing Zotero annotations and mismatch report")
|
||||||
|
final_plan = build_final_writer_plan(positioned_plan)
|
||||||
|
# Attach deletions for writer
|
||||||
|
final_plan["deletions"] = deletions_ids
|
||||||
|
final_plan["is_incremental"] = True
|
||||||
|
final_plan["incremental_stats"] = {
|
||||||
|
"new_clippings": len(new_ids),
|
||||||
|
"prev_integrated": len(prev_integrated_ids),
|
||||||
|
"to_process": len(to_process_ids),
|
||||||
|
"deletions": len(deletions_ids),
|
||||||
|
}
|
||||||
|
mismatch_review = build_mismatch_review(positioned_plan, matches)
|
||||||
|
# For outputs, clippings should be the full new set (for next diff), but plan artifacts are delta
|
||||||
|
clippings = new_clippings
|
||||||
|
else:
|
||||||
|
# Full re-import
|
||||||
|
report_progress(8, "Indexing Zotero", "Reading library items and attachments")
|
||||||
|
zotero_index = build_zotero_index(args.db, args.storage_root)
|
||||||
|
overrides = None
|
||||||
|
if overrides_path.exists():
|
||||||
|
overrides = load_overrides(load_json(str(overrides_path)))
|
||||||
|
clippings = new_clippings
|
||||||
|
report_progress(15, "Matching titles", "Matching Kindle titles to Zotero items")
|
||||||
|
matches = build_match_report(clippings, zotero_index, overrides)
|
||||||
|
override_skeleton = generate_override_skeleton(matches)
|
||||||
|
report_progress(22, "Building plan", "Selecting attachments")
|
||||||
|
plan = build_import_plan(clippings, zotero_index, matches)
|
||||||
|
report_progress(30, "Positioning EPUB highlights", "Locating highlights in EPUB files")
|
||||||
|
epub_plan = add_epub_positions(plan)
|
||||||
|
report_progress(55, "Positioning PDF highlights", "Locating highlights in PDF files; this is usually the longest stage")
|
||||||
|
positioned_plan = add_pdf_positions(epub_plan)
|
||||||
|
report_progress(90, "Finalizing", "Preparing Zotero annotations and mismatch report")
|
||||||
|
final_plan = build_final_writer_plan(positioned_plan)
|
||||||
|
final_plan["deletions"] = []
|
||||||
|
final_plan["is_incremental"] = False
|
||||||
|
mismatch_review = build_mismatch_review(positioned_plan, matches)
|
||||||
|
|
||||||
|
indent = 2 if args.pretty else None
|
||||||
|
outputs = {
|
||||||
|
"clippings": artifact("clippings.json"),
|
||||||
|
"zotero_index": artifact("zotero-index.json"),
|
||||||
|
"matches": artifact("matches.json"),
|
||||||
|
"generated_overrides": artifact("match-overrides.generated.json"),
|
||||||
|
"import_plan": artifact("import-plan.json"),
|
||||||
|
"epub_plan": artifact("import-plan.epub.json"),
|
||||||
|
"positioned_plan": artifact("import-plan.positioned.json"),
|
||||||
|
"final_plan": artifact("import-plan.final.json"),
|
||||||
|
"mismatch_review": workdir / "docs" / "mismatch-review.md",
|
||||||
|
}
|
||||||
|
outputs["mismatch_review"].parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
report_progress(95, "Saving results", "Writing import artifacts")
|
||||||
|
for key, payload in [
|
||||||
|
("clippings", clippings),
|
||||||
|
("zotero_index", zotero_index),
|
||||||
|
("matches", matches),
|
||||||
|
("generated_overrides", override_skeleton),
|
||||||
|
("import_plan", plan),
|
||||||
|
("epub_plan", epub_plan),
|
||||||
|
("positioned_plan", positioned_plan),
|
||||||
|
("final_plan", final_plan),
|
||||||
|
]:
|
||||||
|
with open(outputs[key], "w", encoding="utf-8") as file:
|
||||||
|
json.dump(payload, file, ensure_ascii=False, indent=indent)
|
||||||
|
file.write("\n")
|
||||||
|
|
||||||
|
with open(outputs["mismatch_review"], "w", encoding="utf-8") as file:
|
||||||
|
file.write(mismatch_review)
|
||||||
|
|
||||||
|
status_counts: dict[str, int] = {}
|
||||||
|
for item in positioned_plan.get("items", []):
|
||||||
|
status = item.get("status", "unknown")
|
||||||
|
status_counts[status] = status_counts.get(status, 0) + 1
|
||||||
|
|
||||||
|
match_counts: dict[str, int] = {}
|
||||||
|
for match in matches.get("matches", []):
|
||||||
|
status = match.get("status", "unknown")
|
||||||
|
match_counts[status] = match_counts.get(status, 0) + 1
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"format": "kindle-zotero-importer.pipeline-summary.v1",
|
||||||
|
"workdir": str(workdir),
|
||||||
|
"overrides_path": str(overrides_path),
|
||||||
|
"outputs": {key: str(path) for key, path in outputs.items()},
|
||||||
|
"counts": {
|
||||||
|
"clippings": len(clippings.get("clippings", [])),
|
||||||
|
"unique_titles": len(matches.get("matches", [])),
|
||||||
|
"final_annotations": final_plan.get("annotation_count", 0),
|
||||||
|
"match_statuses": match_counts,
|
||||||
|
"plan_statuses": status_counts,
|
||||||
|
"skipped_final": final_plan.get("skipped_counts", {}),
|
||||||
|
"is_incremental": final_plan.get("is_incremental", False),
|
||||||
|
"incremental_stats": final_plan.get("incremental_stats", {}),
|
||||||
|
"deletions": len(final_plan.get("deletions", [])),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
json_text = json.dumps(summary, ensure_ascii=False, indent=2)
|
||||||
|
if args.summary_output:
|
||||||
|
with open(args.summary_output, "w", encoding="utf-8") as file:
|
||||||
|
file.write(json_text)
|
||||||
|
file.write("\n")
|
||||||
|
else:
|
||||||
|
sys.stdout.write(json_text)
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
report_progress(100, "Pipeline complete", "Ready to import annotations into Zotero")
|
||||||
|
return 0
|
||||||
|
|
||||||
parser.error(f"unknown command: {args.command}")
|
parser.error(f"unknown command: {args.command}")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from dataclasses import dataclass
|
||||||
from html import unescape
|
from html import unescape
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
import posixpath
|
import posixpath
|
||||||
import re
|
|
||||||
import zipfile
|
import zipfile
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -65,8 +64,8 @@ def _position_item(
|
||||||
|
|
||||||
updated = dict(item)
|
updated = dict(item)
|
||||||
annotation = dict(updated["annotation"])
|
annotation = dict(updated["annotation"])
|
||||||
annotation["position"] = position
|
annotation["position"] = position["selector"]
|
||||||
annotation["sortIndex"] = _epub_sort_index(position["value"])
|
annotation["sortIndex"] = position["sortIndex"]
|
||||||
updated["annotation"] = annotation
|
updated["annotation"] = annotation
|
||||||
updated["status"] = "positioned"
|
updated["status"] = "positioned"
|
||||||
return updated
|
return updated
|
||||||
|
|
@ -97,7 +96,7 @@ def extract_epub_text_nodes(path: str) -> list[TextNode]:
|
||||||
return nodes
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
def find_epub_cfi(text_nodes: list[TextNode], quote: str) -> dict[str, str] | None:
|
def find_epub_cfi(text_nodes: list[TextNode], quote: str) -> dict[str, Any] | None:
|
||||||
haystack = " ".join(node.text for node in text_nodes)
|
haystack = " ".join(node.text for node in text_nodes)
|
||||||
normalized_haystack, haystack_map = _normalize_with_map(haystack)
|
normalized_haystack, haystack_map = _normalize_with_map(haystack)
|
||||||
normalized_quote, _ = _normalize_with_map(quote)
|
normalized_quote, _ = _normalize_with_map(quote)
|
||||||
|
|
@ -134,9 +133,12 @@ def find_epub_cfi(text_nodes: list[TextNode], quote: str) -> dict[str, str] | No
|
||||||
f"{end_node.cfi_parent_path}/{end_node.text_step}:{end_offset})"
|
f"{end_node.cfi_parent_path}/{end_node.text_step}:{end_offset})"
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"type": "FragmentSelector",
|
"selector": {
|
||||||
"conformsTo": "http://www.idpf.org/epub/linking/cfi/epub-cfi.html",
|
"type": "FragmentSelector",
|
||||||
"value": value,
|
"conformsTo": "http://www.idpf.org/epub/linking/cfi/epub-cfi.html",
|
||||||
|
"value": value,
|
||||||
|
},
|
||||||
|
"sortIndex": f"{spine_step:05d}|{raw_start:08d}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -257,13 +259,6 @@ def _node_at(nodes: list[TextNode], offset: int) -> tuple[TextNode | None, int]:
|
||||||
return None, 0
|
return None, 0
|
||||||
|
|
||||||
|
|
||||||
def _epub_sort_index(cfi: str) -> str:
|
|
||||||
numbers = [int(number) for number in re.findall(r"/([0-9]+)", cfi)]
|
|
||||||
spine = numbers[1] if len(numbers) > 1 else 0
|
|
||||||
content = numbers[-1] if numbers else 0
|
|
||||||
return f"{spine:05d}|{content:08d}"
|
|
||||||
|
|
||||||
|
|
||||||
def _with_problem(item: dict[str, Any], problem: str) -> dict[str, Any]:
|
def _with_problem(item: dict[str, Any], problem: str) -> dict[str, Any]:
|
||||||
updated = dict(item)
|
updated = dict(item)
|
||||||
updated["status"] = problem
|
updated["status"] = problem
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -20,10 +21,14 @@ def build_final_writer_plan(positioned_plan: dict[str, Any]) -> dict[str, Any]:
|
||||||
skipped.get("positioned-missing-writer-fields", 0) + 1
|
skipped.get("positioned-missing-writer-fields", 0) + 1
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
clipping = item["clipping"]
|
||||||
annotations.append(
|
annotations.append(
|
||||||
{
|
{
|
||||||
"clipping_id": item["clipping"]["id"],
|
"clipping_id": clipping["id"],
|
||||||
"clipping_title": item["clipping"]["title"],
|
"clipping_title": clipping["title"],
|
||||||
|
"clipping_added_on": clipping.get("added_on"),
|
||||||
|
"clipping_added_on_iso": clipping.get("added_on_iso"),
|
||||||
|
"integrated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
"attachment_item_id": attachment["item_id"],
|
"attachment_item_id": attachment["item_id"],
|
||||||
"attachment_key": attachment["key"],
|
"attachment_key": attachment["key"],
|
||||||
"parent_item_id": item["zotero"]["parent_item_id"],
|
"parent_item_id": item["zotero"]["parent_item_id"],
|
||||||
|
|
@ -37,11 +42,22 @@ def build_final_writer_plan(positioned_plan: dict[str, Any]) -> dict[str, Any]:
|
||||||
"pageLabel": annotation.get("pageLabel") or "",
|
"pageLabel": annotation.get("pageLabel") or "",
|
||||||
"sortIndex": annotation.get("sortIndex"),
|
"sortIndex": annotation.get("sortIndex"),
|
||||||
"position": annotation["position"],
|
"position": annotation["position"],
|
||||||
"tags": annotation.get("tags") or [{"name": "kindle-import"}],
|
"tags": [
|
||||||
|
{"name": "kindle-import"},
|
||||||
|
{"name": f"kindle-id:{clipping['id']}"},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
annotations.sort(
|
||||||
|
key=lambda entry: (
|
||||||
|
entry.get("attachment_item_id") or 0,
|
||||||
|
entry["annotation"].get("sortIndex") or "",
|
||||||
|
entry.get("clipping_id") or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"format": FINAL_FORMAT,
|
"format": FINAL_FORMAT,
|
||||||
"source_format": positioned_plan.get("format"),
|
"source_format": positioned_plan.get("format"),
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,31 @@ from dataclasses import dataclass
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_TOOL_PATHS = {
|
||||||
|
"pdftotext": ("/Users/ubd/bin/pdftotext", "/opt/homebrew/bin/pdftotext"),
|
||||||
|
"pdftohtml": ("/Users/ubd/bin/pdftohtml", "/opt/homebrew/bin/pdftohtml"),
|
||||||
|
"pdfinfo": ("/Users/ubd/bin/pdfinfo", "/opt/homebrew/bin/pdfinfo"),
|
||||||
|
"qpdf": ("/Users/ubd/bin/qpdf", "/opt/homebrew/bin/qpdf"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tool(name: str) -> str:
|
||||||
|
resolved = shutil.which(name)
|
||||||
|
if resolved:
|
||||||
|
return resolved
|
||||||
|
for candidate in _TOOL_PATHS.get(name, ()):
|
||||||
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||||
|
return candidate
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PDFWord:
|
class PDFWord:
|
||||||
text: str
|
text: str
|
||||||
|
|
@ -117,7 +136,7 @@ def _position_item(
|
||||||
|
|
||||||
def extract_pdf_text_pages(path: str) -> list[str]:
|
def extract_pdf_text_pages(path: str) -> list[str]:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["pdftotext", "-enc", "UTF-8", path, "-"],
|
[_tool("pdftotext"), "-enc", "UTF-8", path, "-"],
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|
@ -157,7 +176,7 @@ def _decrypted_pdf_path(
|
||||||
base_name = os.path.splitext(os.path.basename(path))[0] or "document"
|
base_name = os.path.splitext(os.path.basename(path))[0] or "document"
|
||||||
output_path = os.path.join(temp_dir, f"{base_name}.{digest}.decrypted.pdf")
|
output_path = os.path.join(temp_dir, f"{base_name}.{digest}.decrypted.pdf")
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["qpdf", "--decrypt", path, output_path],
|
[_tool("qpdf"), "--decrypt", path, output_path],
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|
@ -194,7 +213,7 @@ def extract_pdf_page_xml(path: str, page_index: int) -> PDFPageXML:
|
||||||
page_number = page_index + 1
|
page_number = page_index + 1
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"pdftohtml",
|
_tool("pdftohtml"),
|
||||||
"-f",
|
"-f",
|
||||||
str(page_number),
|
str(page_number),
|
||||||
"-l",
|
"-l",
|
||||||
|
|
@ -302,7 +321,7 @@ def _pdf_page_size(
|
||||||
path: str, page_number: int, fallback_width: float, fallback_height: float
|
path: str, page_number: int, fallback_width: float, fallback_height: float
|
||||||
) -> tuple[float, float]:
|
) -> tuple[float, float]:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["pdfinfo", "-f", str(page_number), "-l", str(page_number), path],
|
[_tool("pdfinfo"), "-f", str(page_number), "-l", str(page_number), path],
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue