From a38ac33596db723fca6af7e337018bf472734713 Mon Sep 17 00:00:00 2001 From: Utku Bilen Demir <84389167+UtkuBilenDemir@users.noreply.github.com> Date: Wed, 27 May 2026 10:49:04 +0200 Subject: [PATCH] Initial Kindle Zotero importer baseline --- .gitignore | 11 + MEMORY.md | 283 ++++ README.md | 87 + docs/import-plan.md | 56 + docs/mismatch-review.md | 1495 +++++++++++++++++ main.py | 4 + maschine/maschine_log_20260527.md | 8 + match-overrides.json | 11 + pyproject.toml | 17 + src/kindle_zotero_importer/__init__.py | 5 + src/kindle_zotero_importer/cli.py | 283 ++++ src/kindle_zotero_importer/clippings.py | 118 ++ src/kindle_zotero_importer/epub_position.py | 271 +++ src/kindle_zotero_importer/final_plan.py | 51 + src/kindle_zotero_importer/import_plan.py | 271 +++ src/kindle_zotero_importer/matcher.py | 209 +++ src/kindle_zotero_importer/mismatch_review.py | 238 +++ src/kindle_zotero_importer/overrides.py | 123 ++ src/kindle_zotero_importer/pdf_position.py | 280 +++ src/kindle_zotero_importer/zotero_index.py | 183 ++ zotero-writer.js | 156 ++ 21 files changed, 4160 insertions(+) create mode 100644 .gitignore create mode 100644 MEMORY.md create mode 100644 README.md create mode 100644 docs/import-plan.md create mode 100644 docs/mismatch-review.md create mode 100644 main.py create mode 100644 maschine/maschine_log_20260527.md create mode 100644 match-overrides.json create mode 100644 pyproject.toml create mode 100644 src/kindle_zotero_importer/__init__.py create mode 100644 src/kindle_zotero_importer/cli.py create mode 100644 src/kindle_zotero_importer/clippings.py create mode 100644 src/kindle_zotero_importer/epub_position.py create mode 100644 src/kindle_zotero_importer/final_plan.py create mode 100644 src/kindle_zotero_importer/import_plan.py create mode 100644 src/kindle_zotero_importer/matcher.py create mode 100644 src/kindle_zotero_importer/mismatch_review.py create mode 100644 src/kindle_zotero_importer/overrides.py create mode 100644 src/kindle_zotero_importer/pdf_position.py create mode 100644 src/kindle_zotero_importer/zotero_index.py create mode 100644 zotero-writer.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f733a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +clippings.json +zotero-index.json +matches.json +match-overrides.generated.json +!match-overrides.json +import-plan*.json +*.import-plan.json +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 0000000..0832161 --- /dev/null +++ b/MEMORY.md @@ -0,0 +1,283 @@ +--- +canvas: + - "[[UTI-roadmap.canvas]]" +UTI-roadmap: [] +--- +# Kindle Zotero Importer Memory + +## Goal + +Import Kindle `My Clippings.txt` highlights/notes into the corresponding Zotero library items as native Zotero annotations where possible. + +Primary source of Kindle data: + +- `/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/Projects/test/My Clippings.txt` + +Zotero database discovered at: + +- `/Users/ubd/Zotero/zotero.sqlite` + +Do not write directly to SQLite. Use Zotero's local JavaScript API/data layer. + +Confirmed title-to-Zotero mappings must be stored in `match-overrides.json` so they persist across cumulative Kindle `My Clippings.txt` exports. `match-overrides.generated.json` is disposable and should not be treated as authoritative. + +## Confirmed Architecture + +Use a two-step importer: + +1. Python-side preview/import-plan generator. + - Parse `My Clippings.txt`. + - Match clipping titles to Zotero items/attachments. + - For EPUB, compute EPUB CFI selectors. + - For PDF, compute page rect coordinates. + - Emit JSON import plan. +2. Zotero-side JavaScript writer. + - Run in Zotero via `Tools > Developer > Run JavaScript` or later as a plugin. + - Load/import generated plan. + - Call `Zotero.Annotations.saveFromJSON(attachment, annotation)`. + +## Working Zotero API + +Native annotations can be created safely with: + +```js +const attachment = Zotero.Items.get(ATTACHMENT_ITEM_ID); +const saved = await Zotero.Annotations.saveFromJSON(attachment, annotation); +``` + +Annotation item type exists internally as Zotero item type `annotation`. + +Native annotation table observed: + +- `itemAnnotations` +- columns include `itemID`, `parentItemID`, `type`, `text`, `comment`, `color`, `pageLabel`, `sortIndex`, `position`, `isExternal` + +## EPUB Test: Cyclonopedia + +Zotero parent item: + +- itemID: `51395` +- key: `JTDWDKRH` +- type: `book` +- citationKey: `negarestani2008` +- title: `Cyclonopedia: complicity with anonymous materials` + +Zotero EPUB attachment: + +- itemID: `51402` +- key: `P6XQUQXF` +- contentType: `application/epub+zip` +- path: `/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Negarestani_2008_Cyclonopedia complicity with anonymous materials.epub` + +Kindle clipping title: + +- `(fixed) Negarestani_2008_Cyclonopedia complicity with anonymous materials (Reza Negarestani)` + +Text matching against the Zotero-linked EPUB worked: + +- Cyclonopedia clipping bodies: `79` +- Highlights: `69` +- Notes: `10` +- Exact text matches: `66` +- Partial text matches: `5` +- Unmatched: `8`, all standalone notes. + +EPUB spine inspection showed `EPUB/paleopetrology.html` is spine slot `/6/30`. + +Working native EPUB test annotation: + +```js +const attachment = Zotero.Items.get(51402); + +const annotation = { + key: Zotero.DataObjectUtilities.generateKey(), + type: "highlight", + text: "Oil as a lubricant or Tellurian Lube, upon which everything moves forward,", + comment: "Kindle import test annotation. Delete after verification.", + color: "#ffd400", + pageLabel: "", + sortIndex: "00014|00047607", + position: { + type: "FragmentSelector", + conformsTo: "http://www.idpf.org/epub/linking/cfi/epub-cfi.html", + value: "epubcfi(/6/30!/4/90/2,/1:3,/1:77)" + }, + tags: [{ name: "kindle-import-test" }] +}; + +await Zotero.Annotations.saveFromJSON(attachment, annotation); +``` + +Important EPUB CFI lesson: + +- Bad CFI navigated but did not render highlight: `epubcfi(/6/30!/4/90/2/1:3,/4/90/2/1:77)` +- Good CFI rendered highlight: `epubcfi(/6/30!/4/90/2,/1:3,/1:77)` +- Zotero's EPUB CFI uses a common parent element plus relative text-node offsets. + +Manual Zotero highlight over a longer range produced: + +```json +{ + "value": "epubcfi(/6/30!/4/90/2,/1:3,/1:317)" +} +``` + +## PDF Test: Rethinking Algorithmic Regulation + +Chose this because it is a normal Zotero item, has a real PDF path, directly matches Kindle clipping text, and had zero existing annotations before the test. + +Zotero parent item: + +- itemID: `350` +- key: `SGZMZ9X7` +- type: `journalArticle` +- citationKey: `medina2015` +- title: `Rethinking algorithmic regulation` +- publicationTitle: `Kybernetes` + +Zotero PDF attachment: + +- itemID: `673` +- key: `4BPF7ZUR` +- contentType: `application/pdf` +- attachment title/path in DB: `attachments:Medina_2015_Rethinking algorithmic regulation.pdf` +- actual file found at: `/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Medina_2015_Rethinking algorithmic regulation.pdf` +- existing annotations before test: `0` + +Kindle clipping title: + +- `Rethinking Algorithmic Regulation Kybern - Unknown` + +Tested clipping text: + +```text +increase worker participation in the economy and preserve the autonomy of factory managers, even with the expansion of state influence. +``` + +Located in PDF: + +- PDF page: `5` +- Zotero `pageIndex`: `4` +- PDF page label: `1009` +- `pdftohtml` XML page dimensions: width `739`, height `1020` +- `pdfinfo` PDF page size: width `493.228`, height `680.315` pts + +Working native PDF test annotation: + +```js +const attachment = Zotero.Items.get(673); + +const annotation = { + key: Zotero.DataObjectUtilities.generateKey(), + type: "highlight", + text: "increase worker participation in the economy and preserve the autonomy of factory managers, even with the expansion of state influence.", + comment: "Kindle import PDF test annotation. Delete after verification.", + color: "#ffd400", + pageLabel: "1009", + sortIndex: "00004|000435|00197", + position: { + pageIndex: 4, + rects: [ + [197.252, 380.843, 445.841, 390.181], + [99.447, 369.504, 403.125, 378.842] + ] + }, + tags: [{ name: "kindle-import-pdf-test" }] +}; + +await Zotero.Annotations.saveFromJSON(attachment, annotation); +``` + +Important PDF lessons: + +- Native PDF annotation writing works. +- PDF positions use `{ pageIndex, rects }`. +- Rect coordinates are in PDF points, origin bottom-left. +- `pdftohtml -xml` coordinates are top-left in its own scaled XML coordinate system. +- Convert from `pdftohtml` XML to PDF points using: + - `scaleX = pdfPageWidth / xmlPageWidth` + - `scaleY = pdfPageHeight / xmlPageHeight` + - `x1 = xmlLeft * scaleX` + - `x2 = (xmlLeft + xmlWidth) * scaleX` + - `y1 = pdfPageHeight - (xmlTop + xmlHeight) * scaleY` + - `y2 = pdfPageHeight - xmlTop * scaleY` +- PDF `sortIndex` needs three numeric parts: `pageIndex|vertical|horizontal`, e.g. `00004|000435|00197`. +- EPUB `sortIndex` accepted two numeric parts, e.g. `00014|00047607`. + +## Test Scripts Created During Exploration + +These were created outside this repo during probing and may be copied/adapted: + +- `/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/Projects/test/create-cyclonopedia-test-annotation.js` +- `/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/Projects/test/list-cyclonopedia-annotations.js` +- `/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/Projects/test/create-latzer-pdf-test-annotation.js` +- `/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/Projects/test/select-medina-pdf-zotero-item.js` +- `/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/Projects/test/select-howaldt-pdf-zotero-item.js` + +## Useful Commands/Tools + +Query Zotero DB read-only while Zotero is running: + +```sh +sqlite3 "file:/Users/ubd/Zotero/zotero.sqlite?mode=ro&immutable=1" "SQL HERE" +``` + +PDF text extraction available: + +```sh +pdftotext -enc UTF-8 -f PAGE -l PAGE file.pdf - +``` + +PDF coordinate extraction available: + +```sh +pdftohtml -f PAGE -l PAGE -xml -stdout file.pdf +``` + +PDF page size: + +```sh +pdfinfo -f PAGE -l PAGE file.pdf +``` + +Python PDF libraries were not installed at exploration time: + +- `fitz` / PyMuPDF: unavailable +- `pypdf`: unavailable + +## Repo/Workspace Decision + +This project lives in the Obsidian vault project area: + +- `/Users/ubd/Library/Mobile Documents/iCloud~md~obsidian/Documents/rhizome/06_projects/UTI/kindle-zotero-importer` + +The vault `.gitignore` ignores it: + +- `06_projects/UTI/kindle-zotero-importer` + +The importer directory has its own independent Git repo: + +- `## No commits yet on main` + +## Next Roadmap + +1. Create project scaffold. +2. Implement Kindle clipping parser. +3. Implement Zotero DB read-only indexer. +4. Implement matching between Kindle titles and Zotero items/attachments. +5. Implement EPUB text matching and CFI generation. +6. Implement PDF text matching and rect generation using `pdftotext`, `pdftohtml`, and `pdfinfo` first. +7. Emit import preview JSON with confidence flags. +8. Build Zotero JavaScript writer that reads preview JSON and calls `Zotero.Annotations.saveFromJSON()`. +9. Add deduplication. Candidate keys should include attachment ID/key, clipping text hash, Kindle location/page, timestamp, and annotation type. +10. Attach standalone Kindle notes to nearest highlight/location as annotation comments where possible; otherwise import as separate text annotations or report unmatched. +11. Generate import report: matched, ambiguous, unmatched, skipped duplicates, created annotations. + +## Cautions + +- Never write directly to Zotero SQLite. +- Test on one item before bulk writes. +- Keep a dry-run/import-preview mode as default. +- Keep tags on imported annotations, e.g. `kindle-import`, for easy deletion/filtering. +- PDF text matching will be more fragile than EPUB because of hyphenation, line breaks, OCR quality, and coordinate extraction. +- Some Kindle notes are standalone and have no quote text; attach by location/page proximity. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8133499 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# Kindle Zotero Importer + +Prototype importer for turning Kindle `My Clippings.txt` entries into a JSON import plan that Zotero can later write as native annotations. + +The project intentionally keeps Zotero writes out of Python. Python parses, matches, and plans. Zotero JavaScript, and eventually a Zotero plugin, will perform annotation creation through Zotero's own APIs. + +## Current Milestone + +Parse Kindle clippings and emit a stable JSON preview: + +```sh +python -m kindle_zotero_importer parse "/path/to/My Clippings.txt" --output clippings.json +``` + +Index Zotero items and PDF/EPUB attachments read-only: + +```sh +python -m kindle_zotero_importer index-zotero --output zotero-index.json +``` + +Match parsed Kindle titles to Zotero items: + +```sh +python -m kindle_zotero_importer match clippings.json zotero-index.json --overrides match-overrides.json --output matches.json +``` + +Generate reusable manual overrides for ambiguous/unmatched titles: + +```sh +python -m kindle_zotero_importer generate-overrides matches.json --output match-overrides.generated.json --pretty +``` + +Apply reviewed overrides: + +```sh +python -m kindle_zotero_importer match clippings.json zotero-index.json --overrides match-overrides.json --output matches.json +``` + +Keep confirmed mappings in `match-overrides.json`. The `match-overrides.generated.json` file is only a disposable review skeleton and can be regenerated from the current `matches.json` at any time. + +If a matched Zotero item has multiple usable PDF/EPUB attachments, add the selected attachment to the same override entry: + +```json +{ + "clipping_title": "Example Kindle Title", + "resolution": { + "citation_key": "example2024", + "attachment_key": "ABC12345" + } +} +``` + +Use either `attachment_key` or `attachment_item_id`. Attachment choices are listed in `docs/mismatch-review.md` when an item is ambiguous. + +For cumulative Kindle exports, rerun the whole pipeline against the latest `My Clippings.txt`. Previously confirmed overrides in `match-overrides.json` will be applied automatically to old and new clippings. + +Review unresolved cases in `docs/mismatch-review.md` after each run. Prioritize: + +1. `matched-title-no-attachment`: add/link the missing PDF or EPUB in Zotero. +2. `unmatched-title`: add a confirmed title mapping to `match-overrides.json`. +3. position failures: improve text/position matching or handle manually. + +Regenerate the copy-friendly review report: + +```sh +python -m kindle_zotero_importer review-mismatches import-plan.positioned.json matches.json --output docs/mismatch-review.md +``` + +Each reviewed title includes a YAML-style block with standalone keys such as `clipping_title`, `citation_key`, `zotero_item_id`, `zotero_key`, and `override_entry` for easy copying. + +Generate a preliminary import plan for matched titles: + +```sh +python -m kindle_zotero_importer plan clippings.json zotero-index.json matches.json --output import-plan.json --pretty +``` + +Position annotations and export the Zotero writer plan: + +```sh +python -m kindle_zotero_importer position-epub import-plan.json --output import-plan.epub.json --pretty +python -m kindle_zotero_importer position-pdf import-plan.epub.json --output import-plan.positioned.json --pretty +python -m kindle_zotero_importer finalize import-plan.positioned.json --output import-plan.final.json --pretty +``` + +## Safety Rule + +Do not write directly to `zotero.sqlite`. Use read-only SQLite access for indexing and Zotero's JavaScript API for writes. diff --git a/docs/import-plan.md b/docs/import-plan.md new file mode 100644 index 0000000..7102963 --- /dev/null +++ b/docs/import-plan.md @@ -0,0 +1,56 @@ +# Import Plan Contract + +The long-term plugin boundary is a JSON contract. Python can generate this contract during prototyping; a future Zotero plugin can either consume it or reimplement the same planning logic in TypeScript. + +## Current Parser Output + +```json +{ + "format": "kindle-zotero-importer.clippings.v1", + "count": 1, + "clippings": [ + { + "id": "stable-hash-prefix", + "title": "Book Title (Author)", + "kind": "highlight", + "text": "Highlighted text", + "raw_detail": "- Your Highlight on page 10 | Location 100-101 | Added on ...", + "page": "10", + "location": "100-101", + "added_on": "Kindle date string", + "added_on_iso": "Parsed ISO datetime when recognized" + } + ] +} +``` + +## Future Import Plan + +The final writer-facing plan should add matched Zotero targets and annotation positions: + +```json +{ + "format": "kindle-zotero-importer.import-plan.v1", + "items": [ + { + "clipping_id": "stable-hash-prefix", + "status": "matched", + "confidence": "exact-text-match", + "zotero": { + "parent_item_id": 51395, + "attachment_item_id": 51402, + "attachment_type": "application/epub+zip" + }, + "annotation": { + "type": "highlight", + "text": "Highlighted text", + "comment": null, + "color": "#ffd400", + "pageLabel": "", + "sortIndex": "00014|00047607", + "position": {} + } + } + ] +} +``` diff --git a/docs/mismatch-review.md b/docs/mismatch-review.md new file mode 100644 index 0000000..02e2f55 --- /dev/null +++ b/docs/mismatch-review.md @@ -0,0 +1,1495 @@ +# Mismatch Review + +Persistent rule: confirmed title matches go in `match-overrides.json`, not `match-overrides.generated.json`. The generated file can be replaced at any time. + +## Matched Zotero Item But No Attachment + +Status: `matched-title-no-attachment` +Clippings: 103 +Unique titles: 1 +Action: Attach/link a PDF or EPUB to the listed Zotero item, then re-index Zotero. + +### Why AI Undermines Democracy and What To Do About It (Coeckelbergh, Mark) + +```yaml +clipping_title: "Why AI Undermines Democracy and What To Do About It (Coeckelbergh, Mark)" +status: matched-title-no-attachment +clipping_count: 103 +kinds: {highlight: 103} +citation_key: "coeckelbergh2025" +zotero_item_id: 34791 +zotero_key: "V3BDW9XL" +zotero_title: "Why AI undermines democracy and what to do about it" +attachment_count: 0 +expected_attachment_type: "pdf-or-epub" +match_score: 0.98 +match_reason: "title-exact" +override_entry: + clipping_title: "Why AI Undermines Democracy and What To Do About It (Coeckelbergh, Mark)" + resolution: + citation_key: "coeckelbergh2025" +``` + +Candidates: +- citation_key: `coeckelbergh2025`, zotero_item_id: `34791`, zotero_key: `V3BDW9XL`, attachments: `0`, score: `0.98`, title: Why AI undermines democracy and what to do about it +- citation_key: `dahlum2017`, zotero_item_id: `437`, zotero_key: `8IF5GLXD`, attachments: `1`, score: `0.92`, title: Democracy by Demand? Reinvestigating the Effect of Self-expression Values on Political Regime Type + +- Example: highlight page `29` loc `678-679`: “organized as processes of communication where humans inform themselves, debate, and take collectively binding decisions” (2023: 12). +- Example: highlight page `29` loc `678-680`: “organized as processes of communication where humans inform themselves, debate, and take collectively binding decisions” (2023: 12). This requires a public sphere: a social sphere +- Example: highlight page `30` loc `686-689`: Dewey and Habermas stressed not only participation but also communication. In Democracy and Education (1944), Dewey sees communication as a process in which participants learn to s + +## Unmatched Kindle Titles + +Status: `unmatched-title` +Clippings: 636 +Unique titles: 42 +Action: Add one confirmed mapping to match-overrides.json using citation_key, zotero_key, or zotero_item_id. + +### WilliamS.Burroughs-Nakedlunch-GrovePress(2001) (Utku) + +```yaml +clipping_title: "WilliamS.Burroughs-Nakedlunch-GrovePress(2001) (Utku)" +status: unmatched-title +clipping_count: 69 +kinds: {highlight: 69} +citation_key: "" +zotero_item_id: null +zotero_key: "burroughs1992" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-or-epub" +match_score: null +match_reason: "" +``` + +- Example: highlight page `8` loc `56-57`: The addict in the street who must have junk to live is the one irreplaceable factor in the junk equation. When there are no more addicts to buy junk there will be no junk trafic. A +- Example: highlight page `15` loc `141-142`: Paregoric Babes of the World Unite. We have nothing to lose but Our Pushers. And THEY are NOT NECESSARY. +- Example: highlight page `19` loc `185-185`: "Well," I said, tapping my arm, "duty calls. As one judge said to another: 'Be just and if you can't be just, be arbitrary.' " + +### The Satanic Verses_ A Novel (Salman Rushdie) + +```yaml +clipping_title: "The Satanic Verses_ A Novel (Salman Rushdie)" +status: unmatched-title +clipping_count: 54 +kinds: {highlight: 54} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "epub-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `None` loc `51-53`: What characteristics which? Slow down; you think Creation happens in a rush? So then, neither does revelation . . . take a look at the pair of them. Notice anything unusual? Just t +- Example: highlight page `None` loc `88-89`: Now that I am dead I have forgotten how to forgive. I curse you, my Gibreel, may your life be hell. Hell, because that's where you sent me, damn you, where you came from, devil, wh +- Example: highlight page `None` loc `103-106`: what had taken him over was the will to live, unadulterated, irresistible, pure, and the first thing it did was to inform him that it wanted nothing to do with his pathetic persona + +### 1984 (AmazonClassics Edition) (Orwell, George) + +```yaml +clipping_title: "1984 (AmazonClassics Edition) (Orwell, George)" +status: unmatched-title +clipping_count: 46 +kinds: {highlight: 46} +citation_key: "foucault1997" +zotero_item_id: 137 +zotero_key: "GHG9JTKY" +zotero_title: "The essential works of Foucault, 1954-1984" +attachment_count: 1 +expected_attachment_type: "pdf-or-epub" +match_score: 0.88 +match_reason: "clipping-title-contained-in-title" +override_entry: + clipping_title: "1984 (AmazonClassics Edition) (Orwell, George)" + resolution: + citation_key: "foucault1997" +``` + +Candidates: +- citation_key: `foucault1997`, zotero_item_id: `137`, zotero_key: `GHG9JTKY`, attachments: `1`, score: `0.88`, title: The essential works of Foucault, 1954-1984 +- citation_key: `bade2018a`, zotero_item_id: `7612`, zotero_key: `V37MBFR3`, attachments: `0`, score: `0.88`, title: Vom Export der Sozialen Frage zur importierten Sozialen Frage: Deutschland im transnationalen Wanderungsgeschehen seit der Mitte des 19. Jahrhunderts [1984/1985] + +- Example: highlight page `9` loc `113-114`: Either the future would resemble the present, in which case it would not listen to him, or it would be different from it, and his predicament would be meaningless. +- Example: highlight page `9` loc `118-118`: At this moment, however, even the monologue had dried up. +- Example: highlight page `9` loc `116-118`: For weeks past he had been making ready for this moment, and it had never crossed his mind that anything would be needed except courage. The actual writing would be easy. All he ha + +### mew_band03 + +```yaml +clipping_title: "mew_band03" +status: unmatched-title +clipping_count: 40 +kinds: {highlight: 40} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `19-19` loc `None`: Diese Abhängigkeit von Hegel ist der Grund, warum keiner dieser neueren Kritiker eine umfassende Kritik des Hegeischen Systems auch nur versuchte, sosehr Jeder von ihnen behauptet, +- Example: highlight page `19-19` loc `None`: Nach und nach wurde jedes herrschende Verhältnis für ein Verhältnis der Religion erklärt und in Kultus verwandelt, Kultus des Rechts, Kultus des Staats pp. Überall hatte man es nur +- Example: highlight page `19-19` loc `None`: Die Althegelianer hatten Alles begriffen, sobald es auf eine Hegeische logische Kategorie zurückgeführt war. Die Junghegelianer kritisierten Alles, indem sie ihm religiöse Vorstell + +### MiloradPaviç_-_ hazar_sözlügü + +```yaml +clipping_title: "MiloradPaviç_-_ hazar_sözlügü" +status: unmatched-title +clipping_count: 37 +kinds: {highlight: 36, note: 1} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `15-15` loc `None`: Tersine çevrilebilir' sanatlar olan mimarlık, heykel ve resimde böyledir durum. Ama başka sanatlar da vardır... Tek yönlü sokaklara, her şeyin başlangıcından sonuna, doğumundan ölü +- Example: highlight page `23-23` loc `None`: Okuyucu dokuzuncu sayfadaki şu sözcükleri okuyunca da ölüyordu: Verbum caro/actum est (Kelam beden oldu). Denetim nüshası zehirli yapıtla birlikte okunduğu takdirde, ölümün yaklaşt +- Example: highlight page `31-31` loc `None`: İki insan düşünün, her biri bir ipin iki ucundan çekiyor ve ipin ortasında da bir puma tutuyorlar böylece. Aynı anda birbirlerine yaklaşmak isteseler, puma saldıracak onlara çünkü + +### DRdZ 01 - Die Suche nach dem Auge der Welt (Robert Jordan) + +```yaml +clipping_title: "DRdZ 01 - Die Suche nach dem Auge der Welt (Robert Jordan)" +status: unmatched-title +clipping_count: 33 +kinds: {highlight: 33} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "epub-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `None` loc `6909-6909`: Egwene zeigte nicht ihre sonst so einwandfreien Manieren; +- Example: highlight page `None` loc `7257-7257`: gebaut in Zeitalter von Legenden, man sagt. Da sein eine +- Example: highlight page `None` loc `7257-7258`: gebaut in Zeitalter von Legenden, man sagt. Da sein eine Wand mit Fries, der zeigt Tiere, die kein lebender + +### Mackenzie_2018_Resistance and the politics of truth Foucault, Deleuze, Badiou (Iain MacKenzie) + +```yaml +clipping_title: "Mackenzie_2018_Resistance and the politics of truth Foucault, Deleuze, Badiou (Iain MacKenzie)" +status: unmatched-title +clipping_count: 33 +kinds: {highlight: 33} +citation_key: "mackenzie2018" +zotero_item_id: 46710 +zotero_key: "QA46VR8D" +zotero_title: "Resistance and the politics of truth: Foucault, Deleuze, Badiou" +attachment_count: 2 +expected_attachment_type: "pdf-or-epub" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +override_entry: + clipping_title: "Mackenzie_2018_Resistance and the politics of truth Foucault, Deleuze, Badiou (Iain MacKenzie)" + resolution: + citation_key: "mackenzie2018" +``` + +Candidates: +- citation_key: `mackenzie2018`, zotero_item_id: `46710`, zotero_key: `QA46VR8D`, attachments: `2`, score: `0.92`, title: Resistance and the politics of truth: Foucault, Deleuze, Badiou +- citation_key: `deleuze1988`, zotero_item_id: `118`, zotero_key: `D4DFMW8L`, attachments: `1`, score: `0.92`, title: Foucault + +- Example: highlight page `189` loc `2324-2325`: All of which amounts to a defence of the idea of critique and its importance in establishing practices of resistance: without an immanent critique of one’s situation there is no ho +- Example: highlight page `189` loc `2329-2330`: The previous chapters have addressed the first of these problems in establishing a general idea of immanent critique as a creative practice; +- Example: highlight page `191` loc `2349-2349`: (because the learner is the thinker of pure difference), + +### Marcus Aurelius (Emperor of Rome), Martin Hammond, Diskin Clay - Meditations (2006, Penguin Books) - libgen.li + +```yaml +clipping_title: "Marcus Aurelius (Emperor of Rome), Martin Hammond, Diskin Clay - Meditations (2006, Penguin Books) - libgen.li" +status: unmatched-title +clipping_count: 33 +kinds: {highlight: 33} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `xv-xv` loc `None`: which show the emperor addressing his troops in a formal adlocutio +- Example: highlight page `xxv-xxv` loc `None`: Throughout the history of the Stoa the emulation of Socrates generated the moral impetus to the way of thought and action distinctive of the Stoa. We find it in Epictetus (c. AD 55 +- Example: highlight page `xxxiv-xxxiv` loc `None`: There are other therapies in Marcus' repertoire of spiritual exercises that are not as difficult to accept as his radical dissecting and 'denuding' of human experience + +### On Populist Reason (Laclau, Ernesto) + +```yaml +clipping_title: "On Populist Reason (Laclau, Ernesto)" +status: unmatched-title +clipping_count: 33 +kinds: {highlight: 33} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-or-epub" +match_score: null +match_reason: "" +``` + +- Example: highlight page `135` loc `2285-2285`: As Kazin points out, until 1940 the notion of a conservative populism was an oxymoron. +- Example: highlight page `135` loc `2285-2287`: As Kazin points out, until 1940 the notion of a conservative populism was an oxymoron. There was no connection between populism and the discourse of the traditional Right, which wa +- Example: highlight page `135` loc `2285-2291`: As Kazin points out, until 1940 the notion of a conservative populism was an oxymoron. There was no connection between populism and the discourse of the traditional Right, which wa + +### Martyr! -- Akbar, Kaveh -- null, null, 2024 -- RANDOM HOUSE US -- 9780593802359 -- 930ede856e2a2efb28505e3c4e99e222 -- Anna’s Archive (Kaveh Akbar) + +```yaml +clipping_title: "Martyr! -- Akbar, Kaveh -- null, null, 2024 -- RANDOM HOUSE US -- 9780593802359 -- 930ede856e2a2efb28505e3c4e99e222 -- Anna’s Archive (Kaveh Akbar)" +status: unmatched-title +clipping_count: 31 +kinds: {highlight: 31} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-or-epub" +match_score: null +match_reason: "" +``` + +- Example: highlight page `21` loc `260-260`: maturation of the masses, these treatments would likely be palliative. +- Example: highlight page `35` loc `428-430`: “I’ve read your poems, Cyrus. I get that you’re Persian. Born there, raised here. I know that’s a part of you. But you’ve probably spent more time looking at your phone today, just +- Example: highlight page `37` loc `459-461`: “That certainty is what put worms in their brains, Cyrus. The only people who speak in certainties are zealots and tyrants.” “Sure, sure. But there’s no tiny secret part of you tha + +### (Kapitalismus und Schizophrenie 2) Guattari, Deleuze - Tausend Plateaus, Mille Plateaux-Les Editions de Minuit (1980) + +```yaml +clipping_title: "(Kapitalismus und Schizophrenie 2) Guattari, Deleuze - Tausend Plateaus, Mille Plateaux-Les Editions de Minuit (1980)" +status: unmatched-title +clipping_count: 27 +kinds: {highlight: 27} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `156-156` loc `None`: Deshalb kann man äußerstenfalls sogar auf den Begriff des Zeichens verzichten, da man prinzipiell nicht die Beziehung zwischen einem Zeichen und einem Sachverhalt, den es kennzeich +- Example: highlight page `156-156` loc `None`: Deshalb kann man äußerstenfalls sogar auf den Begriff des Zeichens verzichten, da man prinzipiell nicht die Beziehung zwischen einem Zeichen und einem Sachverhalt, den es kennzeich +- Example: highlight page `156-156` loc `None`: Das Zeichen hat also schon einen hohen Grad relativer Deterritorialisierung erreicht + +### Arendt_2017_ + +```yaml +clipping_title: "Arendt_2017_" +status: unmatched-title +clipping_count: 20 +kinds: {highlight: 20} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `1-1` loc `None`: This transformation never occurred in the totalitarian, the Bolshevik and the Nazi movements. At the time it seized power the danger to the movement lay in the fact that, on one ha +- Example: highlight page `2-2` loc `None`: The point is that both Hitler and Stalin held out promises of stability in order to hide their intention of creating a state of permanent instability +- Example: highlight page `2-2` loc `None`: For the totalitarian ruler is confronted with a dual task which at first appears contradictory to the point of absurdity: he must establish the fictitious world of the movement as + +### Dune (Frank Herbert) + +```yaml +clipping_title: "Dune (Frank Herbert)" +status: unmatched-title +clipping_count: 18 +kinds: {highlight: 18} +citation_key: "herbert2020" +zotero_item_id: 49237 +zotero_key: "AR2YFJMK" +zotero_title: "Dune messiah" +attachment_count: 1 +expected_attachment_type: "epub-preferred" +match_score: 0.88 +match_reason: "clipping-title-contained-in-title" +override_entry: + clipping_title: "Dune (Frank Herbert)" + resolution: + citation_key: "herbert2020" +``` + +Candidates: +- citation_key: `herbert2020`, zotero_item_id: `49237`, zotero_key: `AR2YFJMK`, attachments: `1`, score: `0.88`, title: Dune messiah +- citation_key: `herbert1969`, zotero_item_id: `49238`, zotero_key: `MKQJ6SD3`, attachments: `0`, score: `0.88`, title: The Dune chronicles. 2: Dune: Messiah / Frank Herbert ; with an introduction by Brian Herbert + +- Example: highlight page `None` loc `109-110`: woman. “Sly little rascal.” She chuckled. “But royalty has need of slyness. +- Example: highlight page `None` loc `243-243`: “Hope clouds observation.” +- Example: highlight page `None` loc `243-243`: She extinguished the excitement, reminding herself: “Hope clouds observation.” + +### paul-lafargue-the-right-to-be-lazy (Paul Lafargue) + +```yaml +clipping_title: "paul-lafargue-the-right-to-be-lazy (Paul Lafargue)" +status: unmatched-title +clipping_count: 17 +kinds: {highlight: 17} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-or-epub" +match_score: null +match_reason: "" +``` + +- Example: highlight page `5` loc `42-43`: For the Spaniard, in whom the primitive animal has not been atrophied, work is the worst sort of slavery. +- Example: highlight page `9` loc `91-92`: Modern factories have become ideal houses of correction in which the toiling masses are imprisoned, +- Example: highlight page `13` loc `156-159`: requires too much violence and makes too much noise. Hunger, on the contrary, is not only a pressure which is peaceful, silent and incessant, but as it is the most natural motive f + +### Fire & Blood (A Song of Ice and Fire) (George R. R. Martin) + +```yaml +clipping_title: "Fire & Blood (A Song of Ice and Fire) (George R. R. Martin)" +status: unmatched-title +clipping_count: 15 +kinds: {highlight: 15} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "epub-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `None` loc `2164-2167`: Regent, in a rivalry that would continue into Jaehaerys’s own reign and threaten to plunge the Seven Kingdoms back into war.*1 The immediate cause of the tension was the king’s sud +- Example: highlight page `None` loc `2236-2237`: of you.” For her, these days on Dragonstone were an idyll. +- Example: highlight page `None` loc `5746-5747`: from Westeros soon after the wedding, crossing the narrow + +### Hardt_Negri_2017_ + +```yaml +clipping_title: "Hardt_Negri_2017_" +status: unmatched-title +clipping_count: 14 +kinds: {highlight: 14} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `xiii-xiii` loc `None`: Save few exceptions, these movements either have abandoned their radical aspirations and become players in the existing systems or have been defeated by ferocious repression +- Example: highlight page `xiii-xiii` loc `None`: Everything turns,” as Hegel says, “on grasping and expressing the True not only as Substance, but equally as Subject.”1 We must train our eyes to recognize how the movements have t +- Example: highlight page `xiii-xiii` loc `None`: Everything turns,” as Hegel says, “on grasping and expressing the True not only as Substance, but equally as Subject.”1 We must train our eyes to recognize how the movements have t + +### Exhalation (Ted Chiang) + +```yaml +clipping_title: "Exhalation (Ted Chiang)" +status: unmatched-title +clipping_count: 13 +kinds: {highlight: 13} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-or-epub" +match_score: null +match_reason: "" +``` + +- Example: highlight page `111` loc `1601-1605`: The researchers conclude that there’s something missing in the Origami genome, but as far as Derek’s concerned, the fault lies with them. They’re blind to a simple truth: complex m +- Example: highlight page `64` loc `902-903`: mutism was very rare, a result of damage to the anterior cingulate region of the brain. Now it spreads like a cognitive plague. +- Example: highlight page `64` loc `901-903`: Before people started playing with Predictors, akinetic mutism was very rare, a result of damage to the anterior cingulate region of the brain. Now it spreads like a cognitive plag + +### Brave New World (Aldous Huxley) + +```yaml +clipping_title: "Brave New World (Aldous Huxley)" +status: unmatched-title +clipping_count: 12 +kinds: {highlight: 12} +citation_key: "sarkisBraveNewWorld2020" +zotero_item_id: 66467 +zotero_key: "NNXL3K3S" +zotero_title: "A brave new world: Lessons from the COVID-19 pandemic for transitioning to sustainable supply and production" +attachment_count: 1 +expected_attachment_type: "epub-preferred" +match_score: 0.88 +match_reason: "clipping-title-contained-in-title" +override_entry: + clipping_title: "Brave New World (Aldous Huxley)" + resolution: + citation_key: "sarkisBraveNewWorld2020" +``` + +Candidates: +- citation_key: `sarkisBraveNewWorld2020`, zotero_item_id: `66467`, zotero_key: `NNXL3K3S`, attachments: `1`, score: `0.88`, title: A brave new world: Lessons from the COVID-19 pandemic for transitioning to sustainable supply and production +- citation_key: `spash2010`, zotero_item_id: `12546`, zotero_key: `PNPR7NVY`, attachments: `1`, score: `0.88`, title: The Brave New World of Carbon Trading + +- Example: highlight page `None` loc `47-50`: squat grey building of only thirty-four stories. Over the main entrance the words, CENTRAL LONDON HATCHERY AND CONDITIONING CENTRE, and, in a shield, the World State’s motto, COMMU +- Example: highlight page `None` loc `57-57`: three hundred Fertilizers were plunged, as the Director of Hatcheries and Conditioning +- Example: highlight page `None` loc `220-222`: “And that,” put in the Director sententiously, “that is the secret of happiness and virtue—liking what you’ve got to do. All conditioning aims at that: making people like their un- + +### Galloway_2004_Protocol + +```yaml +clipping_title: "Galloway_2004_Protocol" +status: unmatched-title +clipping_count: 12 +kinds: {highlight: 12} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `13-13` loc `None`: This book—Protocol—points to one possible path for doing this. Throughout the discussions on power, control, and decentralization, Protocol consistently makes a case for a material +- Example: highlight page `14-14` loc `None`: Further, code is not necessarily language, and certainly not a sign. A code, in the sense that Protocol defnes it, is process-based: It is parsed, compiled, procedural or object-or +- Example: highlight page `14-14` loc `None`: Further, code is not necessarily language, and certainly not a sign. A code, in the sense that Protocol defnes it, is process-based: It is parsed, compiled, procedural or object-or + +### Chantal Mouffe - Hegemonie und radikale Demokratie.-Passagen Verlag Ges.M.B.H (2000) + +```yaml +clipping_title: "Chantal Mouffe - Hegemonie und radikale Demokratie.-Passagen Verlag Ges.M.B.H (2000)" +status: unmatched-title +clipping_count: 8 +kinds: {highlight: 8} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `22-22` loc `None`: ln der analytischen Tradition ist dies die Bewegung, die mit der Philosophie des späten Wittgenstein beginnt und in den verschiedenen Formen post-wittgensieinschen Denkens beibehal +- Example: highlight page `184-184` loc `None`: politische Frage sich auf den Gegensatz Proletariat oder Kapitalisten zuspitzte... Es war schon eine ganz besondere taktische Geschicklichkeit der demokratischen Partei erforderlic +- Example: highlight page `184-184` loc `None`: die Auflösung dieses amorphen „Volkes“ und sein 1 .i set/en durch <|j(. de soziale Basis der Arbeiterklasse hätten populäre Bewegungen die erlangt, die ihnen einen langf ristigen K + +### The City & the City - China Mieville + +```yaml +clipping_title: "The City & the City - China Mieville" +status: unmatched-title +clipping_count: 8 +kinds: {highlight: 8} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `12-12` loc `None`: We would never call inexplicable little insights “hunches,” for fear of drawing the universe’s attention. But they happened, and you knew you had been in the proximity of one that +- Example: highlight page `23-23` loc `None`: groove by the raised spill-stopper, and it had come back clean. The stains +- Example: highlight page `142-142` loc `None`: Not very long ago the equivalent tests would have involved being asked about the different national character of Ul Qomans, and judging who from various pictures with stereotyped p + +### Latzer Just (2020) + +```yaml +clipping_title: "Latzer Just (2020)" +status: unmatched-title +clipping_count: 7 +kinds: {highlight: 7} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `2-2` loc `None`: provided by Gillespie: “encoded procedures for transforming input data into a desired output, based on specified calculations” (Gillespie, 2014, p.167) +- Example: highlight page `5-5` loc `None`: communications, discussions about the governance by algorithms often focus on the influence on opinion formation and consequently the potential for its manipulation during polls an +- Example: highlight page `5-5` loc `None`: which is executed via biased filter bubbles (decreasing variety of content), microtargeting (e.g. , political microtargeting, see Zuiderveen Borgesius et al. , 2018; or online beha + +### Deleuze and Guattaris + +```yaml +clipping_title: "Deleuze and Guattaris" +status: unmatched-title +clipping_count: 6 +kinds: {highlight: 6} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `38-38` loc `None`: productive unconscious, built around the concept of desiring-machines +- Example: highlight page `41-41` loc `None`: What the schizophrenic lives, both as an individual and as a member of the human species, is not at all any one specific aspect of nature, but nature as & process of production' (A +- Example: highlight page `41-41` loc `None`: validity" to mitigate the despair, terror, and boredom of existence'.2 It is not simply that as Lenz sees them, the earth, the wind, the water and so on are infused with a life of + +### Ernesto Laclau - On Populist Reason-Verso (2005) + +```yaml +clipping_title: "Ernesto Laclau - On Populist Reason-Verso (2005)" +status: unmatched-title +clipping_count: 6 +kinds: {highlight: 6} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `127-127` loc `None`: This, as we have seen, leads to one of the demands stepping in and becoming the signifier of the whole chain — a tendentially empty signifier +- Example: highlight page `127-127` loc `None`: This, as we have seen, leads to one of the demands stepping in and becoming the signifier of the whole chain — a tendentially empty signifier. But the whole model depends on the pr +- Example: highlight page `127-127` loc `None`: This, as we have seen, leads to one of the demands stepping in and becoming the signifier of the whole chain — a tendentially empty signifier + +### Lem, Stanislaw - Solaris (Stanisław Lem) + +```yaml +clipping_title: "Lem, Stanislaw - Solaris (Stanisław Lem)" +status: unmatched-title +clipping_count: 6 +kinds: {highlight: 6} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-or-epub" +match_score: null +match_reason: "" +``` + +- Example: highlight page `7` loc `52-53`: extended vast and flat; from the size of the streaks on its surface I could tell I was still a long way off. Or rather, a long way up, since I’d already passed that intangible boun +- Example: highlight page `15` loc `298-300`: In this way it was physicists, not biologists, who proposed the paradoxical formulation “plasmic machine” to refer to a formation that in our sense might be devoid of life, but was +- Example: highlight page `48` loc `1262-1269`: We’re humanitarian and noble, we’ve no intention of subjugating other races, we only want to impart our values to them and in return, to appropriate their heritage. We see ourselve + +### Deleuze, Guattari- A Thousand Plateaus - Bobo + +```yaml +clipping_title: "Deleuze, Guattari- A Thousand Plateaus - Bobo" +status: unmatched-title +clipping_count: 4 +kinds: {highlight: 4} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `172-172` loc `None`: BwO is what remains when you take everything away +- Example: highlight page `172-172` loc `None`: BwO is what remains when you take everything away. What you take away is precisely the phantasy, and signifiances and subjectifications as a whole. Psychoanalysis does the opposite +- Example: highlight page `172-172` loc `None`: BwO is what remains when you take everything away. What you take away is precisely the phantasy, and signifiances and subjectifications as a whole. Psychoanalysis does the opposite + +### Howaldt_Schwarz_2016_SI-DRIVE + +```yaml +clipping_title: "Howaldt_Schwarz_2016_SI-DRIVE" +status: unmatched-title +clipping_count: 4 +kinds: {highlight: 4} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `40-40` loc `None`: transformation possibilities and creative potentials” (Antoniolo 2010, p.13) in society and search “for the conditions of the genesis and production of the new in all areas of life +- Example: highlight page `40-40` loc `None`: Thus the concept of micro-politics focuses on the continuous “differentiations of social practices”, on “the large number of differentially determined, interacting currents which r +- Example: highlight page `41-41` loc `None`: (ibid. ) Micropolitical analysis focuses on these structures and the property of being entangled in structures, or in other words on the relational structures at the micro-level. F + +### (Grundstudium Mathematik) Herbert Amann, Joachim Escher - Analysis 1-Birkhauser Basel (2006) + +```yaml +clipping_title: "(Grundstudium Mathematik) Herbert Amann, Joachim Escher - Analysis 1-Birkhauser Basel (2006)" +status: unmatched-title +clipping_count: 3 +kinds: {highlight: 3} +citation_key: "tao2016" +zotero_item_id: 18 +zotero_key: "I5SP76FN" +zotero_title: "Analysis 1." +attachment_count: 1 +expected_attachment_type: "pdf-preferred" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +override_entry: + clipping_title: "(Grundstudium Mathematik) Herbert Amann, Joachim Escher - Analysis 1-Birkhauser Basel (2006)" + resolution: + citation_key: "tao2016" +``` + +Candidates: +- citation_key: `tao2016`, zotero_item_id: `18`, zotero_key: `I5SP76FN`, attachments: `1`, score: `0.92`, title: Analysis 1. +- citation_key: `millerAnalysis2014`, zotero_item_id: `5270`, zotero_key: `JAPHU5YK`, attachments: `0`, score: `0.92`, title: Analysis + +- Example: highlight page `19-19` loc `None`: Wenn A eine Aussage ist, f¨ ur die wir festsetzen, daß sie genau dann richtig sein soll, wenn die Aussage B wahr ist, schreiben wir A :⇐⇒ B und sagen: ”A gilt defnitionsgem¨ aß gen +- Example: highlight page `21-21` loc `None`: , es gibt genau eine leere Menge; sie wird mit ∅ bezeichnet und ist Teilmenge jeder Menge +- Example: highlight page `21-21` loc `None`: Die leere Menge besitzt jede Eigenschaft + +### Deleuze, Bartleby; or, the Formula + +```yaml +clipping_title: "Deleuze, Bartleby; or, the Formula" +status: unmatched-title +clipping_count: 3 +kinds: {highlight: 3} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "pdf-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `1-1` loc `None`: This is an agrammatical formula that stands as the limit of a series of correct expressions: J'en ai de trop, Je n'en ai pas assez, II m'en manque un ..“I have too many,” “I don't +- Example: highlight page `3-3` loc `None`: If need be, other languages will be summoned into English in order to make it echo this divine language of storm and thunder. Melville invents a foreign language that runs beneath +- Example: highlight page `3-3` loc `None`: If need be, other languages will be summoned into English in order to make it echo this divine language of storm and thunder. Melville invents a foreign language that runs beneath + +### The Republic (Plato) + +```yaml +clipping_title: "The Republic (Plato)" +status: unmatched-title +clipping_count: 3 +kinds: {highlight: 3} +citation_key: "" +zotero_item_id: null +zotero_key: "" +zotero_title: "" +attachment_count: null +expected_attachment_type: "epub-preferred" +match_score: null +match_reason: "" +``` + +- Example: highlight page `None` loc `465-467`: as the government must be supposed to have power, the only reasonable conclusion is, that everywhere there is one principle of justice, which is the interest of the stronger. +- Example: highlight page `None` loc `532-533`: Then, I said, no science or art considers or enjoins the interest of the stronger or superior, but only the interest of the subject and weaker? +- Example: highlight page `None` loc `550-552`: First of all, in private contracts: wherever the unjust is the partner of the just you will find that, when the partnership is dissolved, the unjust man has always more and the jus + +_Showing first 30 of 42 unique titles._ + +## Matched Attachment But Missing File Path + +Status: `pdf-position-missing-path` +Clippings: 41 +Unique titles: 2 +Action: Fix Zotero linked-file/storage path, then re-index Zotero. + +### Anti-Ödipus Kapitalismus und Schizophrenie I by Gilles Deleuze, Félix Guattari (z-lib.org) + +```yaml +clipping_title: "Anti-Ödipus Kapitalismus und Schizophrenie I by Gilles Deleuze, Félix Guattari (z-lib.org)" +status: pdf-position-missing-path +clipping_count: 25 +kinds: {highlight: 25} +citation_key: "deleuze2019" +zotero_item_id: 379 +zotero_key: "3MH2HBHD" +zotero_title: "Anti-Ödipus" +attachment_count: 1 +expected_attachment_type: "pdf" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 19014 + attachment_key: "LQRMJTQI" + attachment_title: "Deleuze_Guattari_2019_.pdf" + content_type: "application/pdf" + path: "attachments:Deleuze_Guattari_2019_.pdf" +override_entry: + clipping_title: "Anti-Ödipus Kapitalismus und Schizophrenie I by Gilles Deleuze, Félix Guattari (z-lib.org)" + resolution: + citation_key: "deleuze2019" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `deleuze2019`, zotero_item_id: `379`, zotero_key: `3MH2HBHD`, attachments: `1`, score: `0.92`, title: Anti-Ödipus +- citation_key: `deleuze1983`, zotero_item_id: `19015`, zotero_key: `3XSVRKYB`, attachments: `3`, score: `0.8706`, title: Anti-Oedipus: capitalism and schizophrenia + +- Example: highlight page `8-8` loc `None`: Nicht Mensch noch Natur sind mehr vorhanden, sondern einzig Prozesse +- Example: highlight page `8-8` loc `None`: Er erlebt die Natur nicht als Natur, sondern als Produktionsprozeß +- Example: highlight page `8-8` loc `None`: Lenz hat die Ebene des Bruchs von Mensch und Natur hinter sich gelassen und befindet sich damit außerhalb der von dieser Trennung bedingten Orientierungsmuster. Er erlebt die Natur + +### Benedict Anderson - Die Erfindung der Nation. Zur Karriere eines folgenreichen Konzepts-Ullstein (1998 [1983]) + +```yaml +clipping_title: "Benedict Anderson - Die Erfindung der Nation. Zur Karriere eines folgenreichen Konzepts-Ullstein (1998 [1983])" +status: pdf-position-missing-path +clipping_count: 16 +kinds: {highlight: 16} +citation_key: "anderson2005" +zotero_item_id: 13693 +zotero_key: "69XQUWIJ" +zotero_title: "Die Erfindung der Nation: zur Karriere eines folgenreichen Konzepts" +attachment_count: 1 +expected_attachment_type: "pdf" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 13696 + attachment_key: "NS3V38TS" + attachment_title: "Anderson_Mergel_2005_.pdf" + content_type: "application/pdf" + path: "attachments:Anderson_Mergel_2005_.pdf" +override_entry: + clipping_title: "Benedict Anderson - Die Erfindung der Nation. Zur Karriere eines folgenreichen Konzepts-Ullstein (1998 [1983])" + resolution: + citation_key: "anderson2005" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `anderson2005`, zotero_item_id: `13693`, zotero_key: `69XQUWIJ`, attachments: `1`, score: `0.92`, title: Die Erfindung der Nation: zur Karriere eines folgenreichen Konzepts + +- Example: highlight page `7-7` loc `None`: der ernsthaft der Überzeugung wäre, daß ein solches Vokabular für die Ereignisse in Indochina wirklich angemessensei. +- Example: highlight page `8-8` loc `None`: Diese Überlegungenunterstreichen die Tatsache, daß sich seit dem Zweiten Weltkriegjede erfolgreiche Revolution inzationalen Begriffen definiert +- Example: highlight page `8-8` loc `None`: Genauer formuliert, müßte man sagen, daß sich der Nationalismusals eineAnomalie des Marxismus + +## Attachment Found But Text Position Failed + +Status: `epub-text-not-found` +Clippings: 181 +Unique titles: 11 +Action: Needs looser EPUB text matching or manual review. + +### Dostoyevsky and Taluy_2020_Karamazov kardeşler + +```yaml +clipping_title: "Dostoyevsky and Taluy_2020_Karamazov kardeşler" +status: epub-text-not-found +clipping_count: 62 +kinds: {highlight: 62} +citation_key: "dostoyevsky2020" +zotero_item_id: 40541 +zotero_key: "2MYIPCEM" +zotero_title: "Karamazov kardeşler" +attachment_count: 2 +expected_attachment_type: "pdf-preferred" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 40547 + attachment_key: "HN83F6ZU" + attachment_title: "Dostoyevsky and Taluy_2020_Karamazov kardeşler 1.epub" + content_type: "application/epub+zip" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Dostoyevsky and Taluy_2020_Karamazov kardeşler 1.epub" + - attachment_item_id: 40543 + attachment_key: "V42288YE" + attachment_title: "Dostoyevsky and Taluy_2020_Karamazov kardeşler.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Dostoyevsky and Taluy_2020_Karamazov kardeşler.pdf" +override_entry: + clipping_title: "Dostoyevsky and Taluy_2020_Karamazov kardeşler" + resolution: + citation_key: "dostoyevsky2020" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `dostoyevsky2020`, zotero_item_id: `40541`, zotero_key: `2MYIPCEM`, attachments: `2`, score: `0.92`, title: Karamazov kardeşler + +- Example: highlight page `VII-VII` loc `None`: En zorlusu da şu son soru... Çünkü bu soruyu ancak, "Bunu, romanı okuyunca anlarsınız! " diye yanıtiayabilirim, ama ya romanı bitirdikten sonra da, Aleksey Fyodoroviç'imde ne özell +- Example: highlight page `VII-VII` loc `None`: Kahramamı Aleksey Fyodoroviç Karamazov'un hayat hikayesine başlarken duraksıyorum biraz. Nedeni de şu: Aleksey Fyodoroviç'i kahraman olarak alıken, onun hiç de büyük bir adam olmad +- Example: highlight page `VIII-VIII` loc `None`: istemezlerse? Üstelik, bunun böyle olacağını şimdiden, üzü­ lerek kestirebiliyorum. Bence bu adamda bazı özellikler var, ama bunu okurlara kanıtiayıp kanıtlayamayacağımdan pek emin + +### Gilles Deleuze, Felix Guattari - Anti-Oedipus_ Capitalism and Schizophrenia-Univ Of Minnesota Press (1983) + +```yaml +clipping_title: "Gilles Deleuze, Felix Guattari - Anti-Oedipus_ Capitalism and Schizophrenia-Univ Of Minnesota Press (1983)" +status: epub-text-not-found +clipping_count: 47 +kinds: {highlight: 47} +citation_key: "deleuze1983" +zotero_item_id: 19015 +zotero_key: "3XSVRKYB" +zotero_title: "Anti-Oedipus: capitalism and schizophrenia" +attachment_count: 3 +expected_attachment_type: "pdf-preferred" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 42874 + attachment_key: "5PMKEQHR" + attachment_title: "Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia 1.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia 1.pdf" + - attachment_item_id: 42876 + attachment_key: "HUBBR88Z" + attachment_title: "Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia.epub" + content_type: "application/epub+zip" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia.epub" + - attachment_item_id: 42873 + attachment_key: "WRX7QDU5" + attachment_title: "Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia.pdf" +override_entry: + clipping_title: "Gilles Deleuze, Felix Guattari - Anti-Oedipus_ Capitalism and Schizophrenia-Univ Of Minnesota Press (1983)" + resolution: + citation_key: "deleuze1983" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `deleuze1983`, zotero_item_id: `19015`, zotero_key: `3XSVRKYB`, attachments: `3`, score: `0.92`, title: Anti-Oedipus: capitalism and schizophrenia + +- Example: highlight page `25-25` loc `None`: "What does my father want? Can he offer me more than that? Impossible. Leave me in peace."1 Everything is a machine. Celestial machines, the stars or rainbows in the sky, alpine ma +- Example: highlight page `28-28` loc `None`: The process should work to a completion, not to some horror of intensification and extremity wherein the soul and body ultimately perish."5 Schizophrenia is like love: there is no +- Example: highlight page `37-37` loc `None`: to sit down. I must have tried to explain."12 We cannot say that psychoanalysis is very innovative in this respect: it continues to ask its questions and develop its interpretation + +### William S. Burroughs - The Soft Machine-Grove Press (1992) + +```yaml +clipping_title: "William S. Burroughs - The Soft Machine-Grove Press (1992)" +status: epub-text-not-found +clipping_count: 32 +kinds: {highlight: 32} +citation_key: "burroughs2012" +zotero_item_id: 48780 +zotero_key: "GB54JACW" +zotero_title: "The soft machine" +attachment_count: 2 +expected_attachment_type: "pdf-preferred" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 73104 + attachment_key: "QKYEIMI6" + attachment_title: "Burroughs_2012_The soft machine.epub" + content_type: "application/epub+zip" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Burroughs_2012_The soft machine.epub" + - attachment_item_id: 73106 + attachment_key: "29NYGBJ9" + attachment_title: "Burroughs_2012_The soft machine.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Burroughs_2012_The soft machine.pdf" +override_entry: + clipping_title: "William S. Burroughs - The Soft Machine-Grove Press (1992)" + resolution: + citation_key: "burroughs2012" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `burroughs2012`, zotero_item_id: `48780`, zotero_key: `GB54JACW`, attachments: `2`, score: `0.92`, title: The soft machine + +- Example: highlight page `5-5` loc `None`: So he imports this special breed of scorpions and feeds them on metal meal and the scorpions turned a phosphorescent blue color and sort of hummed. "Now we must find a worthy vesse +- Example: highlight page `30-30` loc `None`: Switch Artist me. Oh, there goes my frequency. I'm on +- Example: highlight page `37-37` loc `None`: More routine work—I studied Mayan and listened to it on the tape recorder and mixed Mayan in with English—I made innumerable photomontages of Mayan codices and artifacts—the next s + +### The Communist Hypothesis (Alain Badiou) + +```yaml +clipping_title: "The Communist Hypothesis (Alain Badiou)" +status: epub-text-not-found +clipping_count: 15 +kinds: {highlight: 15} +citation_key: "badiou2015" +zotero_item_id: 472 +zotero_key: "GEH63EJT" +zotero_title: "The communist hypothesis" +attachment_count: 2 +expected_attachment_type: "epub-preferred" +match_score: 0.98 +match_reason: "title-exact" +attachment_choices: + - attachment_item_id: 73096 + attachment_key: "WVAYQNEG" + attachment_title: "Badiou_2015_The communist hypothesis.epub" + content_type: "application/epub+zip" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Badiou_2015_The communist hypothesis.epub" + - attachment_item_id: 73098 + attachment_key: "WR4TFLUZ" + attachment_title: "Badiou_2015_The communist hypothesis.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Badiou_2015_The communist hypothesis.pdf" +override_entry: + clipping_title: "The Communist Hypothesis (Alain Badiou)" + resolution: + citation_key: "badiou2015" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `badiou2015`, zotero_item_id: `472`, zotero_key: `GEH63EJT`, attachments: `2`, score: `0.98`, title: The communist hypothesis + +- Example: highlight page `None` loc `80-87`: for example, that ‘Fermat’s theorem’ is a hypothesis if we formulate it as: ‘For >n, I assume that the equation xn + yn = zn has no whole solutions (solutions in which x, y and z a +- Example: highlight page `None` loc `619-623`: The bourgeoisie had indeed found a convenient hiding place inside the party, and the means to forge its new power, as we can see from China today, now that it has embarked upon a n +- Example: highlight page `None` loc `905-907`: I use the term ‘saturation’ in the sense given to it by Sylvain Lazarus;2 I will attempt to show that the Cultural Revolution is the + +### Holland_2013_Deleuze and Guattari's A thousand plateaus a reader's guide (Holland, Eugene W.) + +```yaml +clipping_title: "Holland_2013_Deleuze and Guattari's A thousand plateaus a reader's guide (Holland, Eugene W.)" +status: epub-text-not-found +clipping_count: 13 +kinds: {highlight: 13} +citation_key: "holland2013" +zotero_item_id: 45058 +zotero_key: "MR7YD8FW" +zotero_title: "Deleuze and Guattari's A thousand plateaus: a reader's guide" +attachment_count: 1 +expected_attachment_type: "epub" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 45062 + attachment_key: "DVSTKU2W" + attachment_title: "Holland_2013_Deleuze and Guattari's A thousand plateaus a reader's guide.epub" + content_type: "application/epub+zip" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Holland_2013_Deleuze and Guattari's A thousand plateaus a reader's guide.epub" +override_entry: + clipping_title: "Holland_2013_Deleuze and Guattari's A thousand plateaus a reader's guide (Holland, Eugene W.)" + resolution: + citation_key: "holland2013" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `holland2013`, zotero_item_id: `45058`, zotero_key: `MR7YD8FW`, attachments: `1`, score: `0.92`, title: Deleuze and Guattari's A thousand plateaus: a reader's guide + +- Example: highlight page `41` loc `520-522`: debt; whereas Marx considered production to be primary, Deleuze & Guattari draw here on Nietzsche more than Marx, and instead consider debt to be the primary organizing element in +- Example: highlight page `41` loc `513-516`: The economic instance, in other words, is a Problematic virtual structure (how to produce means of life to assure survival) to which various societies (or a given society at variou +- Example: highlight page `41` loc `520-523`: debt; whereas Marx considered production to be primary, Deleuze & Guattari draw here on Nietzsche more than Marx, and instead consider debt to be the primary organizing element in + +### (fixed) Negarestani_2008_Cyclonopedia complicity with anonymous materials (Reza Negarestani) + +```yaml +clipping_title: "(fixed) Negarestani_2008_Cyclonopedia complicity with anonymous materials (Reza Negarestani)" +status: epub-text-not-found +clipping_count: 5 +kinds: {highlight: 5} +citation_key: "negarestani2008" +zotero_item_id: 51395 +zotero_key: "JTDWDKRH" +zotero_title: "Cyclonopedia: complicity with anonymous materials" +attachment_count: 1 +expected_attachment_type: "epub" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 51402 + attachment_key: "P6XQUQXF" + attachment_title: "Negarestani_2008_Cyclonopedia complicity with anonymous materials 1.epub" + content_type: "application/epub+zip" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Negarestani_2008_Cyclonopedia complicity with anonymous materials.epub" +override_entry: + clipping_title: "(fixed) Negarestani_2008_Cyclonopedia complicity with anonymous materials (Reza Negarestani)" + resolution: + citation_key: "negarestani2008" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `negarestani2008`, zotero_item_id: `51395`, zotero_key: `JTDWDKRH`, attachments: `1`, score: `0.92`, title: Cyclonopedia: complicity with anonymous materials + +- Example: highlight page `25` loc `194-196`: Blackouts … seeing rodents climbing the walls, thousands, millions of them like wingless birds flying through the void. It vaguely reminds me of F. M. Cornford’s The Unwritten Phil +- Example: highlight page `90` loc `932-935`: The surface is where most of the action is. The surface is where light reflected or absorbed, not the interior of the substance. The surface what touches the animal, not the interi +- Example: highlight page `97` loc `1025-1027`: Without a ground, that is to say, in the absence of a power formation, the definition of ‘power’ is basically undermined. What is politics, then, when there is power but no power f + +### Beckett_2009_Three novels (Samuel Beckett) + +```yaml +clipping_title: "Beckett_2009_Three novels (Samuel Beckett)" +status: epub-text-not-found +clipping_count: 2 +kinds: {highlight: 2} +citation_key: "beckett2009" +zotero_item_id: 42879 +zotero_key: "95BBKLYJ" +zotero_title: "Three novels" +attachment_count: 1 +expected_attachment_type: "epub" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 42882 + attachment_key: "SK7YDIFX" + attachment_title: "Beckett_2009_Three novels.epub" + content_type: "application/epub+zip" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Beckett_2009_Three novels.epub" +override_entry: + clipping_title: "Beckett_2009_Three novels (Samuel Beckett)" + resolution: + citation_key: "beckett2009" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `beckett2009`, zotero_item_id: `42879`, zotero_key: `95BBKLYJ`, attachments: `1`, score: `0.92`, title: Three novels + +- Example: highlight page `518` loc `6641-6647`: But when it falters? And when it stops? But it falters every instant, it stops every instant! Yes, but when it stops for a good few moments, a good few moments (what are a good few +- Example: highlight page `517` loc `6633-6641`: + +### Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia (Gilles Deleuze, Felix Guattari) + +```yaml +clipping_title: "Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia (Gilles Deleuze, Felix Guattari)" +status: epub-text-not-found +clipping_count: 2 +kinds: {highlight: 2} +citation_key: "deleuze1983" +zotero_item_id: 19015 +zotero_key: "3XSVRKYB" +zotero_title: "Anti-Oedipus: capitalism and schizophrenia" +attachment_count: 3 +expected_attachment_type: "pdf-or-epub" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 42874 + attachment_key: "5PMKEQHR" + attachment_title: "Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia 1.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia 1.pdf" + - attachment_item_id: 42876 + attachment_key: "HUBBR88Z" + attachment_title: "Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia.epub" + content_type: "application/epub+zip" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia.epub" + - attachment_item_id: 42873 + attachment_key: "WRX7QDU5" + attachment_title: "Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia.pdf" +override_entry: + clipping_title: "Deleuze and Guattari_1983_Anti-Oedipus capitalism and schizophrenia (Gilles Deleuze, Felix Guattari)" + resolution: + citation_key: "deleuze1983" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `deleuze1983`, zotero_item_id: `19015`, zotero_key: `3XSVRKYB`, attachments: `3`, score: `0.92`, title: Anti-Oedipus: capitalism and schizophrenia + +- Example: highlight page `551` loc `7237-7248`: The choice is between one of two poles, the paranoiac counterescape that motivates all the conformist, reactionary, and fascisizing investments, and the schizophrenic escape conver +- Example: highlight page `552` loc `7256-7258`: the neurotic needs a global object in relation to which the partial objects can be determined as a lack, and inversely,38 But on a more general level, the statistical transformatio + +### Buchner_2004_Lenz (Georg Buchner) + +```yaml +clipping_title: "Buchner_2004_Lenz (Georg Buchner)" +status: epub-text-not-found +clipping_count: 1 +kinds: {highlight: 1} +citation_key: "buchner2004" +zotero_item_id: 42869 +zotero_key: "AA8YM2SD" +zotero_title: "Lenz" +attachment_count: 1 +expected_attachment_type: "epub" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 42872 + attachment_key: "8ZMLY422" + attachment_title: "Buchner_2004_Lenz.epub" + content_type: "application/epub+zip" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Buchner_2004_Lenz.epub" +override_entry: + clipping_title: "Buchner_2004_Lenz (Georg Buchner)" + resolution: + citation_key: "buchner2004" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `buchner2004`, zotero_item_id: `42869`, zotero_key: `AA8YM2SD`, attachments: `1`, score: `0.92`, title: Lenz + +- Example: highlight page `14` loc `140-143`: Laß in mir die heil’gen Schmerzen, Tiefe Bronnen ganz aufbrechen; Leiden sei all’mein Gewinst, Leiden sei mein Gottesdienst. + +### Herbert and Herbert_2020_Dune messiah (Frank Herbert) + +```yaml +clipping_title: "Herbert and Herbert_2020_Dune messiah (Frank Herbert)" +status: epub-text-not-found +clipping_count: 1 +kinds: {highlight: 1} +citation_key: "herbert2020" +zotero_item_id: 49237 +zotero_key: "AR2YFJMK" +zotero_title: "Dune messiah" +attachment_count: 1 +expected_attachment_type: "epub" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 49241 + attachment_key: "BWN8LXZP" + attachment_title: "Herbert and Herbert_2020_Dune messiah.epub" + content_type: "application/epub+zip" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Herbert and Herbert_2020_Dune messiah.epub" +override_entry: + clipping_title: "Herbert and Herbert_2020_Dune messiah (Frank Herbert)" + resolution: + citation_key: "herbert2020" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `herbert2020`, zotero_item_id: `49237`, zotero_key: `AR2YFJMK`, attachments: `1`, score: `0.92`, title: Dune messiah + +- Example: highlight page `65` loc `817-820`: Empires do not suffer emptiness of purpose at the time of their creation. It is when they have become established that aims are lost and replaced by vague ritual. —Words of Muad’Di + +### Sterren et al._2011_Fundamental chess openings (Paul van der Sterren) + +```yaml +clipping_title: "Sterren et al._2011_Fundamental chess openings (Paul van der Sterren)" +status: epub-text-not-found +clipping_count: 1 +kinds: {highlight: 1} +citation_key: "sterren2011" +zotero_item_id: 542 +zotero_key: "G2R9WX2P" +zotero_title: "Fundamental chess openings" +attachment_count: 2 +expected_attachment_type: "epub-preferred" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 43906 + attachment_key: "QW6E2MJL" + attachment_title: "Sterren et al._2011_Fundamental chess openings.epub" + content_type: "application/epub+zip" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Sterren et al._2011_Fundamental chess openings.epub" + - attachment_item_id: 43908 + attachment_key: "IJ68KXNQ" + attachment_title: "Sterren et al._2011_Fundamental chess openings.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Sterren et al._2011_Fundamental chess openings.pdf" +override_entry: + clipping_title: "Sterren et al._2011_Fundamental chess openings (Paul van der Sterren)" + resolution: + citation_key: "sterren2011" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `sterren2011`, zotero_item_id: `542`, zotero_key: `G2R9WX2P`, attachments: `2`, score: `0.92`, title: Fundamental chess openings + +- Example: highlight page `None` loc `11774-11776`: The Taimanov, the Paulsen and the Scheveningen are the great main lines in this position. There are also a few smaller variations, among which one is especially notable because it + +## PDF Text Not Found + +Status: `pdf-text-not-found` +Clippings: 25 +Unique titles: 4 +Action: Needs looser PDF text matching/OCR/page-offset handling. + +### (Undergraduate texts in mathematics) Sheldon Axler - Linear Algebra Done Right-Springer (1997) + +```yaml +clipping_title: "(Undergraduate texts in mathematics) Sheldon Axler - Linear Algebra Done Right-Springer (1997)" +status: pdf-text-not-found +clipping_count: 9 +kinds: {highlight: 9} +citation_key: "axler2024" +zotero_item_id: 49383 +zotero_key: "535KUABN" +zotero_title: "Linear Algebra Done Right" +attachment_count: 1 +expected_attachment_type: "pdf" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 49385 + attachment_key: "BY36XN8X" + attachment_title: "Axler_2024_Linear Algebra Done Right.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Axler_2024_Linear Algebra Done Right.pdf" +override_entry: + clipping_title: "(Undergraduate texts in mathematics) Sheldon Axler - Linear Algebra Done Right-Springer (1997)" + resolution: + citation_key: "axler2024" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `axler2024`, zotero_item_id: `49383`, zotero_key: `535KUABN`, attachments: `1`, score: `0.92`, title: Linear Algebra Done Right + +- Example: highlight page `37-37` loc `None`: A list (v1, ... , vm) of vectors in V is called linearly independent if the only choice of a1, ... , am ∈ F that makes a1v1 + · · · + amvm equal 0 is a1 = · · · = am = 0. For examp +- Example: highlight page `37-37` loc `None`: Infnite-dimensional vector spaces, which we will not mention much anymore, are the center of attention in the branch of mathematics called functional analysis. Functional analysis +- Example: highlight page `38-38` loc `None`: Most linear algebra texts defne linearly independent sets instead of linearly independent lists. With that defnition, the set {(0, 1), (0, 1), (1, 0)} is linearly independent in F2 + +### Buchanan_2008_Deleuze and Guattari's Anti-Oedipus a reader's guide + +```yaml +clipping_title: "Buchanan_2008_Deleuze and Guattari's Anti-Oedipus a reader's guide" +status: pdf-text-not-found +clipping_count: 9 +kinds: {highlight: 9} +citation_key: "buchanan2008" +zotero_item_id: 380 +zotero_key: "MBGL8DAK" +zotero_title: "Deleuze and Guattari's Anti-Oedipus: a reader's guide" +attachment_count: 1 +expected_attachment_type: "pdf" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 40764 + attachment_key: "VXSNTSKV" + attachment_title: "Buchanan_2008_Deleuze and Guattari's Anti-Oedipus a reader's guide.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Buchanan_2008_Deleuze and Guattari's Anti-Oedipus a reader's guide.pdf" +override_entry: + clipping_title: "Buchanan_2008_Deleuze and Guattari's Anti-Oedipus a reader's guide" + resolution: + citation_key: "buchanan2008" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `buchanan2008`, zotero_item_id: `380`, zotero_key: `MBGL8DAK`, attachments: `1`, score: `0.92`, title: Deleuze and Guattari's Anti-Oedipus: a reader's guide + +- Example: highlight page `67-67` loc `None`: The first paralogism of psychoanalysis, the paralogism of extrapolation, is this: psychoanalysis converts a detachable partial object - e.g., the penis - into a detached complete o +- Example: highlight page `101-101` loc `None`: The voice is the voice of the alliance, the marked body is the body of affiliation, and the appreciative eye enables the declension of the two. Primitive inscription should not be +- Example: highlight page `101-101` loc `None`: The voice is the voice of the alliance, the marked body is the body of affiliation, and the appreciative eye enables the declension of the two. Primitive inscription should not be + +### Bartleby, the Scrivener (Melville, Herman) + +```yaml +clipping_title: "Bartleby, the Scrivener (Melville, Herman)" +status: pdf-text-not-found +clipping_count: 6 +kinds: {highlight: 6} +citation_key: "melville2006" +zotero_item_id: 41142 +zotero_key: "YF4FV42C" +zotero_title: "Bartleby the scrivener" +attachment_count: 1 +expected_attachment_type: "pdf" +match_score: 0.98 +match_reason: "title-exact" +attachment_choices: + - attachment_item_id: 41144 + attachment_key: "5WIFUMAC" + attachment_title: "Melville_2006_Bartleby the scrivener.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Melville_2006_Bartleby the scrivener.pdf" +override_entry: + clipping_title: "Bartleby, the Scrivener (Melville, Herman)" + resolution: + citation_key: "melville2006" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `melville2006`, zotero_item_id: `41142`, zotero_key: `YF4FV42C`, attachments: `1`, score: `0.98`, title: Bartleby the scrivener + +- Example: highlight page `None` loc `146-148`: In this very attitude did I sit when I called to him, rapidly stating what it was I wanted him to do—namely, to examine a small paper with me. Imagine my surprise, nay, my constern +- Example: highlight page `None` loc `146-151`: In this very attitude did I sit when I called to him, rapidly stating what it was I wanted him to do—namely, to examine a small paper with me. Imagine my surprise, nay, my constern +- Example: highlight page `None` loc `146-152`: In this very attitude did I sit when I called to him, rapidly stating what it was I wanted him to do—namely, to examine a small paper with me. Imagine my surprise, nay, my constern + +### Tiamat - İhsan Oktay Anar-cdeKey_8F425D6B854B4614AD13E11A3106C4D6 + +```yaml +clipping_title: "Tiamat - İhsan Oktay Anar-cdeKey_8F425D6B854B4614AD13E11A3106C4D6" +status: pdf-text-not-found +clipping_count: 1 +kinds: {highlight: 1} +citation_key: "anar2022" +zotero_item_id: 46119 +zotero_key: "L99TYIWY" +zotero_title: "Tiamat" +attachment_count: 1 +expected_attachment_type: "pdf" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 47609 + attachment_key: "WZCI29B3" + attachment_title: "Tiamat - İhsan Oktay Anar.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Tiamat - İhsan Oktay Anar.pdf" +override_entry: + clipping_title: "Tiamat - İhsan Oktay Anar-cdeKey_8F425D6B854B4614AD13E11A3106C4D6" + resolution: + citation_key: "anar2022" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `anar2022`, zotero_item_id: `46119`, zotero_key: `L99TYIWY`, attachments: `1`, score: `0.92`, title: Tiamat + +- Example: highlight page `132-132` loc `None`: Adamın suratı kızarmış, gözleri öfkeden yine büyümüş­ tü. O an hâkim olan sessizliği Kibar bozdu: “Dimitrakopulo ve Gel Ey Esmerim.” “Ne?” “Senin o altınından kalan son kuruşla Daz + +## PDF Rectangles Not Found + +Status: `pdf-rects-not-found` +Clippings: 147 +Unique titles: 3 +Action: Needs improved PDF rectangle recovery after text/page match. + +### Buchanan_2008_Deleuze and Guattari's Anti-Oedipus a reader's guide + +```yaml +clipping_title: "Buchanan_2008_Deleuze and Guattari's Anti-Oedipus a reader's guide" +status: pdf-rects-not-found +clipping_count: 136 +kinds: {highlight: 136} +citation_key: "buchanan2008" +zotero_item_id: 380 +zotero_key: "MBGL8DAK" +zotero_title: "Deleuze and Guattari's Anti-Oedipus: a reader's guide" +attachment_count: 1 +expected_attachment_type: "pdf" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 40764 + attachment_key: "VXSNTSKV" + attachment_title: "Buchanan_2008_Deleuze and Guattari's Anti-Oedipus a reader's guide.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Buchanan_2008_Deleuze and Guattari's Anti-Oedipus a reader's guide.pdf" +override_entry: + clipping_title: "Buchanan_2008_Deleuze and Guattari's Anti-Oedipus a reader's guide" + resolution: + citation_key: "buchanan2008" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `buchanan2008`, zotero_item_id: `380`, zotero_key: `MBGL8DAK`, attachments: `1`, score: `0.92`, title: Deleuze and Guattari's Anti-Oedipus: a reader's guide + +- Example: highlight page `8-8` loc `None`: They were particularly opposed to the Left Bank Maoism that blossomed that spring like the proverbial hundredflowers.They rejected the idea that May '68 constituted a first or prel +- Example: highlight page `8-8` loc `None`: -Oedipus is a May '68 book providing it is understood that Deleuze and Guattari were not 'soixante-huitards' or May 68ers and that their view of those events was quite different fr +- Example: highlight page `9-9` loc `None`: hose who evaluated things in macropolitical terms understood nothing of the event because something unaccountable was escaping. The politicians, the parties, the unions, many lefti + +### Tiamat - İhsan Oktay Anar-cdeKey_8F425D6B854B4614AD13E11A3106C4D6 + +```yaml +clipping_title: "Tiamat - İhsan Oktay Anar-cdeKey_8F425D6B854B4614AD13E11A3106C4D6" +status: pdf-rects-not-found +clipping_count: 10 +kinds: {highlight: 10} +citation_key: "anar2022" +zotero_item_id: 46119 +zotero_key: "L99TYIWY" +zotero_title: "Tiamat" +attachment_count: 1 +expected_attachment_type: "pdf" +match_score: 0.92 +match_reason: "title-contained-in-clipping-title" +attachment_choices: + - attachment_item_id: 47609 + attachment_key: "WZCI29B3" + attachment_title: "Tiamat - İhsan Oktay Anar.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Tiamat - İhsan Oktay Anar.pdf" +override_entry: + clipping_title: "Tiamat - İhsan Oktay Anar-cdeKey_8F425D6B854B4614AD13E11A3106C4D6" + resolution: + citation_key: "anar2022" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `anar2022`, zotero_item_id: `46119`, zotero_key: `L99TYIWY`, attachments: `1`, score: `0.92`, title: Tiamat + +- Example: highlight page `14-14` loc `None`: bir aydır su ve sabun ile temas dahi etmemiş bedenleri üzeri Tabiat Ana, apışarası ve koltukaltında kokusundan anlaşılan, hatta bizzat parmakla yakalanıp çıt diye ezilen canlılarda +- Example: highlight page `28-28` loc `None`: Mürettebatın en genci olan Hamamcı idi bu, hem bâkir hem de habaza olduğu için, kovanlarında kolayca kaysınlar diye baş taraf torpidolarını gresle ona yağlatırlardı +- Example: highlight page `76-76` loc `None`: Bunu duyunca Kumandan o anda yerinden hışımla fırlayarak erbaş yatak yerinin fosfor bronzu kaportasını açıp içeri atladığında, geçen nice seneden sonra yine ilk kez, korkunun ta ke + +### Bartleby, the Scrivener (Melville, Herman) + +```yaml +clipping_title: "Bartleby, the Scrivener (Melville, Herman)" +status: pdf-rects-not-found +clipping_count: 1 +kinds: {highlight: 1} +citation_key: "melville2006" +zotero_item_id: 41142 +zotero_key: "YF4FV42C" +zotero_title: "Bartleby the scrivener" +attachment_count: 1 +expected_attachment_type: "pdf" +match_score: 0.98 +match_reason: "title-exact" +attachment_choices: + - attachment_item_id: 41144 + attachment_key: "5WIFUMAC" + attachment_title: "Melville_2006_Bartleby the scrivener.pdf" + content_type: "application/pdf" + path: "/Users/ubd/Library/Mobile Documents/com~apple~CloudDocs/03_Academia/ZoteroFiles/Melville_2006_Bartleby the scrivener.pdf" +override_entry: + clipping_title: "Bartleby, the Scrivener (Melville, Herman)" + resolution: + citation_key: "melville2006" + attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY" + attachment_item_id: null +``` + +Candidates: +- citation_key: `melville2006`, zotero_item_id: `41142`, zotero_key: `YF4FV42C`, attachments: `1`, score: `0.98`, title: Bartleby the scrivener + +- Example: highlight page `None` loc `250-251`: sudden spasmodic passions with him. For it was exceeding difficult diff --git a/main.py b/main.py new file mode 100644 index 0000000..ff0cc64 --- /dev/null +++ b/main.py @@ -0,0 +1,4 @@ +from kindle_zotero_importer.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/maschine/maschine_log_20260527.md b/maschine/maschine_log_20260527.md new file mode 100644 index 0000000..62a4110 --- /dev/null +++ b/maschine/maschine_log_20260527.md @@ -0,0 +1,8 @@ +--- +kind: maschine +project: "[[kindle-zotero-importer]]" +--- + +- Started project status review from repo files, generated artifacts, git status, README, and MEMORY.md. +- Found independent Git repo on main with no commits yet; tracked source/docs/config are uncommitted, generated pipeline outputs are ignored by .gitignore. +- Current generated pipeline state: 2,109 clippings parsed; 23,185 Zotero items indexed; 67 unique title matches reviewed; final writer plan contains 770 positioned annotations and skips unresolved match, attachment, and positioning cases. diff --git a/match-overrides.json b/match-overrides.json new file mode 100644 index 0000000..9e2a8e4 --- /dev/null +++ b/match-overrides.json @@ -0,0 +1,11 @@ +{ + "format": "kindle-zotero-importer.match-overrides.v1", + "overrides": [ + { + "clipping_title": "WilliamS.Burroughs-Nakedlunch-GrovePress(2001) (Utku)", + "resolution": { + "citation_key": "burroughs1992" + } + } + ] +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8f51b33 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "kindle-zotero-importer" +version = "0.1.0" +description = "Generate Zotero annotation import plans from Kindle My Clippings.txt files." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +[project.scripts] +kindle-zotero-importer = "kindle_zotero_importer.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/kindle_zotero_importer"] diff --git a/src/kindle_zotero_importer/__init__.py b/src/kindle_zotero_importer/__init__.py new file mode 100644 index 0000000..f2dd245 --- /dev/null +++ b/src/kindle_zotero_importer/__init__.py @@ -0,0 +1,5 @@ +"""Kindle to Zotero annotation import planning.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/src/kindle_zotero_importer/cli.py b/src/kindle_zotero_importer/cli.py new file mode 100644 index 0000000..1ad9e8f --- /dev/null +++ b/src/kindle_zotero_importer/cli.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import argparse +import json +import sys + +from .clippings import clippings_to_jsonable, load_clippings +from .epub_position import add_epub_positions +from .final_plan import build_final_writer_plan +from .import_plan import build_import_plan +from .matcher import build_match_report, load_json +from .mismatch_review import build_mismatch_review +from .overrides import generate_override_skeleton, load_overrides +from .pdf_position import add_pdf_positions +from .zotero_index import build_zotero_index + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="kindle-zotero-importer", + description="Generate Zotero import-plan data from Kindle clippings.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + parse_parser = subparsers.add_parser( + "parse", help="Parse a Kindle My Clippings.txt file" + ) + parse_parser.add_argument("clippings_file", help="Path to Kindle My Clippings.txt") + parse_parser.add_argument( + "--output", "-o", help="Write JSON to this file instead of stdout" + ) + parse_parser.add_argument( + "--pretty", action="store_true", help="Pretty-print JSON output" + ) + + index_parser = subparsers.add_parser( + "index-zotero", help="Index Zotero items and attachments read-only" + ) + index_parser.add_argument( + "--db", default="/Users/ubd/Zotero/zotero.sqlite", help="Path to zotero.sqlite" + ) + index_parser.add_argument( + "--storage-root", + default="/Users/ubd/Zotero/storage", + help="Path to Zotero storage directory for storage: attachments", + ) + index_parser.add_argument( + "--output", "-o", help="Write JSON to this file instead of stdout" + ) + index_parser.add_argument( + "--pretty", action="store_true", help="Pretty-print JSON output" + ) + + match_parser = subparsers.add_parser( + "match", help="Match parsed Kindle clipping titles to Zotero items" + ) + match_parser.add_argument("clippings_json", help="Path to parsed clippings JSON") + match_parser.add_argument("zotero_index_json", help="Path to Zotero index JSON") + match_parser.add_argument( + "--output", "-o", help="Write JSON to this file instead of stdout" + ) + match_parser.add_argument( + "--overrides", help="Path to reusable match overrides JSON" + ) + match_parser.add_argument( + "--pretty", action="store_true", help="Pretty-print JSON output" + ) + + overrides_parser = subparsers.add_parser( + "generate-overrides", + help="Generate a reusable override skeleton from ambiguous/unmatched matches", + ) + overrides_parser.add_argument("matches_json", help="Path to match report JSON") + overrides_parser.add_argument( + "--output", "-o", help="Write JSON to this file instead of stdout" + ) + overrides_parser.add_argument( + "--pretty", action="store_true", help="Pretty-print JSON output" + ) + + plan_parser = subparsers.add_parser( + "plan", help="Generate a preliminary Zotero annotation import plan" + ) + plan_parser.add_argument("clippings_json", help="Path to parsed clippings JSON") + plan_parser.add_argument("zotero_index_json", help="Path to Zotero index JSON") + plan_parser.add_argument("matches_json", help="Path to match report JSON") + plan_parser.add_argument( + "--output", "-o", help="Write JSON to this file instead of stdout" + ) + plan_parser.add_argument( + "--pretty", action="store_true", help="Pretty-print JSON output" + ) + + epub_parser = subparsers.add_parser( + "position-epub", help="Add EPUB CFI positions to an import plan" + ) + epub_parser.add_argument( + "import_plan_json", help="Path to preliminary import plan JSON" + ) + epub_parser.add_argument( + "--output", "-o", help="Write JSON to this file instead of stdout" + ) + epub_parser.add_argument( + "--pretty", action="store_true", help="Pretty-print JSON output" + ) + + pdf_parser = subparsers.add_parser( + "position-pdf", help="Add PDF page/rect positions to an import plan" + ) + pdf_parser.add_argument("import_plan_json", help="Path to import plan JSON") + pdf_parser.add_argument( + "--output", "-o", help="Write JSON to this file instead of stdout" + ) + pdf_parser.add_argument( + "--pretty", action="store_true", help="Pretty-print JSON output" + ) + + final_parser = subparsers.add_parser( + "finalize", + help="Export positioned annotations for the Zotero JavaScript writer", + ) + final_parser.add_argument( + "positioned_plan_json", help="Path to positioned import plan JSON" + ) + final_parser.add_argument( + "--output", "-o", help="Write JSON to this file instead of stdout" + ) + final_parser.add_argument( + "--pretty", action="store_true", help="Pretty-print JSON output" + ) + + review_parser = subparsers.add_parser( + "review-mismatches", + help="Generate a copy-friendly mismatch review Markdown file", + ) + review_parser.add_argument( + "positioned_plan_json", help="Path to positioned import plan JSON" + ) + review_parser.add_argument("matches_json", help="Path to match report JSON") + review_parser.add_argument( + "--output", "-o", help="Write Markdown to this file instead of stdout" + ) + + args = parser.parse_args(argv) + + if args.command == "parse": + payload = clippings_to_jsonable(load_clippings(args.clippings_file)) + json_text = json.dumps( + payload, ensure_ascii=False, indent=2 if args.pretty else None + ) + if args.output: + with open(args.output, "w", encoding="utf-8") as file: + file.write(json_text) + file.write("\n") + else: + sys.stdout.write(json_text) + sys.stdout.write("\n") + return 0 + + if args.command == "index-zotero": + payload = build_zotero_index(args.db, args.storage_root) + json_text = json.dumps( + payload, ensure_ascii=False, indent=2 if args.pretty else None + ) + if args.output: + with open(args.output, "w", encoding="utf-8") as file: + file.write(json_text) + file.write("\n") + else: + sys.stdout.write(json_text) + sys.stdout.write("\n") + return 0 + + if args.command == "match": + overrides = ( + load_overrides(load_json(args.overrides)) if args.overrides else None + ) + payload = build_match_report( + load_json(args.clippings_json), load_json(args.zotero_index_json), overrides + ) + json_text = json.dumps( + payload, ensure_ascii=False, indent=2 if args.pretty else None + ) + if args.output: + with open(args.output, "w", encoding="utf-8") as file: + file.write(json_text) + file.write("\n") + else: + sys.stdout.write(json_text) + sys.stdout.write("\n") + return 0 + + if args.command == "generate-overrides": + payload = generate_override_skeleton(load_json(args.matches_json)) + json_text = json.dumps( + payload, ensure_ascii=False, indent=2 if args.pretty else None + ) + if args.output: + with open(args.output, "w", encoding="utf-8") as file: + file.write(json_text) + file.write("\n") + else: + sys.stdout.write(json_text) + sys.stdout.write("\n") + return 0 + + if args.command == "plan": + payload = build_import_plan( + load_json(args.clippings_json), + load_json(args.zotero_index_json), + load_json(args.matches_json), + ) + json_text = json.dumps( + payload, ensure_ascii=False, indent=2 if args.pretty else None + ) + if args.output: + with open(args.output, "w", encoding="utf-8") as file: + file.write(json_text) + file.write("\n") + else: + sys.stdout.write(json_text) + sys.stdout.write("\n") + return 0 + + if args.command == "position-epub": + payload = add_epub_positions(load_json(args.import_plan_json)) + json_text = json.dumps( + payload, ensure_ascii=False, indent=2 if args.pretty else None + ) + if args.output: + with open(args.output, "w", encoding="utf-8") as file: + file.write(json_text) + file.write("\n") + else: + sys.stdout.write(json_text) + sys.stdout.write("\n") + return 0 + + if args.command == "position-pdf": + payload = add_pdf_positions(load_json(args.import_plan_json)) + json_text = json.dumps( + payload, ensure_ascii=False, indent=2 if args.pretty else None + ) + if args.output: + with open(args.output, "w", encoding="utf-8") as file: + file.write(json_text) + file.write("\n") + else: + sys.stdout.write(json_text) + sys.stdout.write("\n") + return 0 + + if args.command == "finalize": + payload = build_final_writer_plan(load_json(args.positioned_plan_json)) + json_text = json.dumps( + payload, ensure_ascii=False, indent=2 if args.pretty else None + ) + if args.output: + with open(args.output, "w", encoding="utf-8") as file: + file.write(json_text) + file.write("\n") + else: + sys.stdout.write(json_text) + sys.stdout.write("\n") + return 0 + + if args.command == "review-mismatches": + text = build_mismatch_review( + load_json(args.positioned_plan_json), load_json(args.matches_json) + ) + if args.output: + with open(args.output, "w", encoding="utf-8") as file: + file.write(text) + else: + sys.stdout.write(text) + return 0 + + parser.error(f"unknown command: {args.command}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/kindle_zotero_importer/clippings.py b/src/kindle_zotero_importer/clippings.py new file mode 100644 index 0000000..03f64ad --- /dev/null +++ b/src/kindle_zotero_importer/clippings.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime +import hashlib +import re +from typing import Any + + +ENTRY_SEPARATOR = "==========" +DETAIL_RE = re.compile( + r"^- Your (?PHighlight|Note|Bookmark)" + r"(?: on page (?P[^|]+?))?" + r"(?: (?:\| location|at location) (?P[^|]+?))?" + r"(?: \| Added on (?P.+))?$", + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class Clipping: + id: str + title: str + kind: str + text: str + raw_detail: str + page: str | None = None + location: str | None = None + added_on: str | None = None + added_on_iso: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def parse_clippings_text(text: str) -> list[Clipping]: + entries = [entry.strip() for entry in text.split(ENTRY_SEPARATOR) if entry.strip()] + clippings: list[Clipping] = [] + + for entry in entries: + lines = entry.splitlines() + if len(lines) < 2: + continue + + title = lines[0].strip() + raw_detail = lines[1].strip() + body = "\n".join(line.rstrip() for line in lines[2:]).strip() + match = DETAIL_RE.match(raw_detail) + + if match: + kind = match.group("kind").lower() + page = _clean(match.group("page")) + location = _clean(match.group("location")) + added_on = _clean(match.group("date")) + else: + kind = "unknown" + page = None + location = None + added_on = None + + clippings.append( + Clipping( + id=_stable_id(title, raw_detail, body), + title=title, + kind=kind, + text=body, + raw_detail=raw_detail, + page=page, + location=location, + added_on=added_on, + added_on_iso=_parse_kindle_date(added_on), + ) + ) + + return clippings + + +def load_clippings(path: str) -> list[Clipping]: + with open(path, "r", encoding="utf-8-sig") as file: + return parse_clippings_text(file.read()) + + +def clippings_to_jsonable(clippings: list[Clipping]) -> dict[str, Any]: + return { + "format": "kindle-zotero-importer.clippings.v1", + "count": len(clippings), + "clippings": [clipping.to_dict() for clipping in clippings], + } + + +def _clean(value: str | None) -> str | None: + if value is None: + return None + cleaned = value.strip() + return cleaned or None + + +def _stable_id(title: str, raw_detail: str, text: str) -> str: + digest = hashlib.sha256( + f"{title}\n{raw_detail}\n{text}".encode("utf-8") + ).hexdigest() + return digest[:16] + + +def _parse_kindle_date(value: str | None) -> str | None: + if not value: + return None + + for fmt in ( + "%A, %B %d, %Y %I:%M:%S %p", + "%A, %d %B %Y %H:%M:%S", + "%A, %B %d, %Y %H:%M:%S", + ): + try: + return datetime.strptime(value, fmt).isoformat() + except ValueError: + pass + return None diff --git a/src/kindle_zotero_importer/epub_position.py b/src/kindle_zotero_importer/epub_position.py new file mode 100644 index 0000000..8639ee3 --- /dev/null +++ b/src/kindle_zotero_importer/epub_position.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +from dataclasses import dataclass +from html import unescape +from html.parser import HTMLParser +import posixpath +import re +import zipfile +import xml.etree.ElementTree as ET +from typing import Any + + +CONTAINER_PATH = "META-INF/container.xml" + + +@dataclass(frozen=True) +class TextNode: + spine_index: int + cfi_parent_path: str + text_step: str + text: str + start: int + end: int + + +def add_epub_positions(plan: dict[str, Any]) -> dict[str, Any]: + cache: dict[str, list[TextNode]] = {} + positioned = [] + for item in plan["items"]: + positioned.append(_position_item(item, cache)) + + status_counts: dict[str, int] = {} + for item in positioned: + status_counts[item["status"]] = status_counts.get(item["status"], 0) + 1 + + updated = dict(plan) + updated["items"] = positioned + updated["status_counts"] = status_counts + return updated + + +def _position_item( + item: dict[str, Any], cache: dict[str, list[TextNode]] +) -> dict[str, Any]: + if item.get("status") != "ready-for-positioning": + return item + attachment = item.get("zotero", {}).get("attachment") + if not attachment or attachment.get("content_type") != "application/epub+zip": + return item + if item["clipping"]["kind"] != "highlight" or not item["clipping"].get("text"): + return _with_problem(item, "epub-position-skipped-non-highlight") + + path = attachment.get("resolved_path") or attachment.get("path") + if not path: + return _with_problem(item, "epub-position-missing-path") + + try: + text_nodes = cache.setdefault(path, extract_epub_text_nodes(path)) + position = find_epub_cfi(text_nodes, item["clipping"]["text"]) + except Exception as error: # noqa: BLE001 - preserve failure in plan, do not abort batch + return _with_problem(item, f"epub-position-error:{error}") + + if not position: + return _with_problem(item, "epub-text-not-found") + + updated = dict(item) + annotation = dict(updated["annotation"]) + annotation["position"] = position + annotation["sortIndex"] = _epub_sort_index(position["value"]) + updated["annotation"] = annotation + updated["status"] = "positioned" + return updated + + +def extract_epub_text_nodes(path: str) -> list[TextNode]: + with zipfile.ZipFile(path) as epub: + opf_path = _opf_path(epub) + spine_items = _spine_items(epub, opf_path) + nodes: list[TextNode] = [] + cursor = 0 + for spine_index, item_path in enumerate(spine_items): + try: + xml = epub.read(item_path).decode("utf-8", errors="replace") + except KeyError: + continue + for cfi_parent_path, text_step, text in _xhtml_text_nodes(xml): + if not text.strip(): + continue + start = cursor + cursor += len(text) + nodes.append( + TextNode( + spine_index, cfi_parent_path, text_step, text, start, cursor + ) + ) + cursor += 1 + return nodes + + +def find_epub_cfi(text_nodes: list[TextNode], quote: str) -> dict[str, str] | None: + haystack = " ".join(node.text for node in text_nodes) + normalized_haystack, haystack_map = _normalize_with_map(haystack) + normalized_quote, _ = _normalize_with_map(quote) + if not normalized_quote: + return None + + match_start = normalized_haystack.find(normalized_quote) + if match_start < 0: + return None + match_end = match_start + len(normalized_quote) + raw_start = haystack_map[match_start] + raw_end = haystack_map[match_end - 1] + 1 + + # Account for the spaces inserted between text nodes in haystack construction. + adjusted_nodes = _nodes_for_joined_text(text_nodes) + start_node, start_offset = _node_at(adjusted_nodes, raw_start) + end_node, end_offset = _node_at(adjusted_nodes, raw_end) + if not start_node or not end_node: + return None + + if start_node.spine_index != end_node.spine_index: + return None + + spine_step = 2 * (start_node.spine_index + 1) + if start_node.cfi_parent_path == end_node.cfi_parent_path: + value = ( + f"epubcfi(/6/{spine_step}!{start_node.cfi_parent_path}," + f"/{start_node.text_step}:{start_offset},/{end_node.text_step}:{end_offset})" + ) + else: + value = ( + f"epubcfi(/6/{spine_step}!" + f"{start_node.cfi_parent_path}/{start_node.text_step}:{start_offset}," + f"{end_node.cfi_parent_path}/{end_node.text_step}:{end_offset})" + ) + return { + "type": "FragmentSelector", + "conformsTo": "http://www.idpf.org/epub/linking/cfi/epub-cfi.html", + "value": value, + } + + +def _opf_path(epub: zipfile.ZipFile) -> str: + root = ET.fromstring(epub.read(CONTAINER_PATH)) + rootfile = root.find(".//{*}rootfile") + if rootfile is None: + raise ValueError("EPUB container has no rootfile") + return rootfile.attrib["full-path"] + + +def _spine_items(epub: zipfile.ZipFile, opf_path: str) -> list[str]: + root = ET.fromstring(epub.read(opf_path)) + manifest = { + item.attrib["id"]: item.attrib["href"] + for item in root.findall(".//{*}manifest/{*}item") + } + base = posixpath.dirname(opf_path) + paths = [] + for itemref in root.findall(".//{*}spine/{*}itemref"): + href = manifest.get(itemref.attrib.get("idref", "")) + if href: + paths.append(posixpath.normpath(posixpath.join(base, href))) + return paths + + +def _xhtml_text_nodes(xml: str) -> list[tuple[str, str, str]]: + parser = _CFIHTMLParser() + parser.feed(xml) + return parser.nodes + + +class _CFIHTMLParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.stack: list[dict[str, Any]] = [] + self.nodes: list[tuple[str, str, str]] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + del attrs + tag = tag.lower() + if not self.stack: + path: list[str] = [] if tag == "html" else ["2"] + else: + parent = self.stack[-1] + parent["element_count"] += 1 + path = [*parent["path"], str(2 * parent["element_count"])] + self.stack.append( + {"tag": tag, "path": path, "element_count": 0, "text_count": 0} + ) + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + for index in range(len(self.stack) - 1, -1, -1): + if self.stack[index]["tag"] == tag: + del self.stack[index:] + return + + def handle_data(self, data: str) -> None: + if not self.stack or not data.strip(): + return + current = self.stack[-1] + current["text_count"] += 1 + text_step = str(2 * current["text_count"] - 1) + self.nodes.append( + ("/" + "/".join(current["path"]), text_step, _clean_text(data)) + ) + + +def _clean_text(text: str) -> str: + return unescape(text).replace("\xa0", " ") + + +def _normalize_with_map(text: str) -> tuple[str, list[int]]: + normalized = [] + mapping = [] + last_was_space = False + for index, char in enumerate(text): + if char.isspace(): + if not last_was_space and normalized: + normalized.append(" ") + mapping.append(index) + last_was_space = True + continue + normalized.append(char.casefold()) + mapping.append(index) + last_was_space = False + if normalized and normalized[-1] == " ": + normalized.pop() + mapping.pop() + return "".join(normalized), mapping + + +def _nodes_for_joined_text(text_nodes: list[TextNode]) -> list[TextNode]: + nodes = [] + cursor = 0 + for node in text_nodes: + start = cursor + end = start + len(node.text) + nodes.append( + TextNode( + node.spine_index, + node.cfi_parent_path, + node.text_step, + node.text, + start, + end, + ) + ) + cursor = end + 1 + return nodes + + +def _node_at(nodes: list[TextNode], offset: int) -> tuple[TextNode | None, int]: + for node in nodes: + if node.start <= offset <= node.end: + return node, max(0, min(offset - node.start, len(node.text))) + 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]: + updated = dict(item) + updated["status"] = problem + updated["problems"] = [*item.get("problems", []), problem] + return updated diff --git a/src/kindle_zotero_importer/final_plan.py b/src/kindle_zotero_importer/final_plan.py new file mode 100644 index 0000000..682882e --- /dev/null +++ b/src/kindle_zotero_importer/final_plan.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Any + + +FINAL_FORMAT = "kindle-zotero-importer.zotero-writer-plan.v1" + + +def build_final_writer_plan(positioned_plan: dict[str, Any]) -> dict[str, Any]: + annotations = [] + skipped: dict[str, int] = {} + for item in positioned_plan["items"]: + if item.get("status") != "positioned": + skipped[item["status"]] = skipped.get(item["status"], 0) + 1 + continue + attachment = item["zotero"]["attachment"] + annotation = item["annotation"] + if not annotation.get("position"): + skipped["positioned-missing-writer-fields"] = ( + skipped.get("positioned-missing-writer-fields", 0) + 1 + ) + continue + annotations.append( + { + "clipping_id": item["clipping"]["id"], + "clipping_title": item["clipping"]["title"], + "attachment_item_id": attachment["item_id"], + "attachment_key": attachment["key"], + "parent_item_id": item["zotero"]["parent_item_id"], + "parent_key": item["zotero"]["parent_key"], + "citation_key": item["zotero"].get("citation_key"), + "annotation": { + "type": annotation["type"], + "text": annotation.get("text") or "", + "comment": annotation.get("comment") or "", + "color": annotation.get("color") or "#ffd400", + "pageLabel": annotation.get("pageLabel") or "", + "sortIndex": annotation.get("sortIndex"), + "position": annotation["position"], + "tags": annotation.get("tags") or [{"name": "kindle-import"}], + }, + } + ) + + return { + "format": FINAL_FORMAT, + "source_format": positioned_plan.get("format"), + "annotation_count": len(annotations), + "skipped_counts": skipped, + "annotations": annotations, + } diff --git a/src/kindle_zotero_importer/import_plan.py b/src/kindle_zotero_importer/import_plan.py new file mode 100644 index 0000000..bff5685 --- /dev/null +++ b/src/kindle_zotero_importer/import_plan.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import re +from typing import Any + + +PLAN_FORMAT = "kindle-zotero-importer.import-plan.v1" + + +def build_import_plan( + clippings_payload: dict[str, Any], + zotero_index: dict[str, Any], + match_report: dict[str, Any], +) -> dict[str, Any]: + items_by_id = {item["item_id"]: item for item in zotero_index["items"]} + matches_by_title = { + match["clipping_title"]: match for match in match_report["matches"] + } + overrides_by_title = { + match["clipping_title"]: (match.get("override") or {}) + for match in match_report["matches"] + } + clippings = _attach_notes_to_highlights(clippings_payload["clippings"]) + plan_items = [ + _plan_clipping(clipping, matches_by_title, items_by_id, overrides_by_title) + for clipping in clippings + if clipping["kind"] in {"highlight", "note"} + ] + status_counts: dict[str, int] = {} + for item in plan_items: + status_counts[item["status"]] = status_counts.get(item["status"], 0) + 1 + + return { + "format": PLAN_FORMAT, + "source": { + "clippings_format": clippings_payload.get("format"), + "zotero_index_format": zotero_index.get("format"), + "match_report_format": match_report.get("format"), + }, + "count": len(plan_items), + "status_counts": status_counts, + "items": plan_items, + } + + +def _plan_clipping( + clipping: dict[str, Any], + matches_by_title: dict[str, dict[str, Any]], + items_by_id: dict[int, dict[str, Any]], + overrides_by_title: dict[str, dict[str, Any]], +) -> dict[str, Any]: + base = { + "clipping": clipping, + "status": "unmatched-title", + "match": None, + "zotero": None, + "annotation": None, + "problems": [], + } + match = matches_by_title.get(clipping["title"]) + if not match or match.get("status") != "matched" or not match.get("candidates"): + return base + + candidate = match["candidates"][0] + if not candidate.get("item_id"): + base["match"] = candidate + base["problems"] = [candidate.get("reason", "unresolved-match")] + return base + + zotero_item = items_by_id.get(candidate["item_id"]) + if not zotero_item: + base["status"] = "missing-zotero-item" + base["match"] = candidate + base["problems"] = ["matched item is absent from Zotero index"] + return base + + attachment, attachment_status, problems = _choose_attachment( + zotero_item, overrides_by_title.get(clipping["title"]) or {} + ) + status = "ready-for-positioning" if attachment else attachment_status + return { + "clipping": clipping, + "status": status, + "match": candidate, + "zotero": { + "parent_item_id": zotero_item["item_id"], + "parent_key": zotero_item["key"], + "parent_title": zotero_item.get("title"), + "citation_key": zotero_item["fields"].get("citationKey"), + "attachment": attachment, + "attachment_choices": zotero_item.get("attachments", []), + "expected_attachment_type": _expected_attachment_type( + clipping, zotero_item.get("attachments", []) + ), + }, + "annotation": _annotation_stub(clipping) if attachment else None, + "problems": problems, + } + + +def _choose_attachment( + zotero_item: dict[str, Any], + override: dict[str, Any] | None = None, +) -> tuple[dict[str, Any] | None, str, list[str]]: + attachments = zotero_item.get("attachments", []) + if not attachments: + return ( + None, + "matched-title-no-attachment", + ["matched Zotero item has no PDF/EPUB attachment"], + ) + if override: + attachment, problem = _attachment_from_override(attachments, override) + if attachment: + return ( + attachment, + "ready-for-positioning", + ["selected attachment by override"], + ) + if problem: + return None, "matched-title-attachment-override-unresolved", [problem] + if len(attachments) == 1: + return attachments[0], "ready-for-positioning", [] + + epubs = [ + attachment + for attachment in attachments + if attachment.get("content_type") == "application/epub+zip" + ] + if len(epubs) == 1: + return ( + epubs[0], + "ready-for-positioning", + ["multiple attachments; selected sole EPUB"], + ) + + return ( + None, + "matched-title-ambiguous-attachment", + [f"matched Zotero item has {len(attachments)} PDF/EPUB attachments"], + ) + + +def _expected_attachment_type( + clipping: dict[str, Any], attachments: list[dict[str, Any]] +) -> str: + content_types = {attachment.get("content_type") for attachment in attachments} + if len(content_types) == 1: + content_type = next(iter(content_types)) + if content_type == "application/epub+zip": + return "epub" + if content_type == "application/pdf": + return "pdf" + if clipping.get("page") and not clipping.get("location"): + return "pdf-preferred" + if clipping.get("location") and not clipping.get("page"): + return "epub-preferred" + return "pdf-or-epub" + + +def _attachment_from_override( + attachments: list[dict[str, Any]], override: dict[str, Any] +) -> tuple[dict[str, Any] | None, str | None]: + if "attachment_item_id" in override: + value = int(override["attachment_item_id"]) + matches = [ + attachment for attachment in attachments if attachment["item_id"] == value + ] + if len(matches) == 1: + return matches[0], None + return ( + None, + f"attachment_item_id override did not match one attachment: {value}", + ) + if "attachment_key" in override: + value = str(override["attachment_key"]).casefold() + matches = [ + attachment + for attachment in attachments + if attachment["key"].casefold() == value + ] + if len(matches) == 1: + return matches[0], None + return ( + None, + f"attachment_key override did not match one attachment: {override['attachment_key']}", + ) + return None, None + + +def _annotation_stub(clipping: dict[str, Any]) -> dict[str, Any]: + annotation_type = "highlight" if clipping["kind"] == "highlight" else "note" + return { + "type": annotation_type, + "text": clipping["text"] if annotation_type == "highlight" else "", + "comment": clipping.get("comment") + or (clipping["text"] if annotation_type == "note" else None), + "color": "#ffd400", + "pageLabel": clipping.get("page") or "", + "sortIndex": None, + "position": None, + "tags": [{"name": "kindle-import"}], + } + + +def _attach_notes_to_highlights( + clippings: list[dict[str, Any]], +) -> list[dict[str, Any]]: + updated = [dict(clipping) for clipping in clippings] + attached_note_ids = set() + for note_index, note in enumerate(updated): + if note["kind"] != "note" or not note.get("text"): + continue + highlight_index = _matching_highlight_index(updated, note_index, note) + if highlight_index is None: + continue + highlight = dict(updated[highlight_index]) + comments = [ + comment for comment in (highlight.get("comment"), note["text"]) if comment + ] + highlight["comment"] = "\n\n".join(comments) + highlight["note_ids"] = [*highlight.get("note_ids", []), note["id"]] + updated[highlight_index] = highlight + attached_note_ids.add(note["id"]) + return [ + clipping + for clipping in updated + if clipping["kind"] != "note" or clipping["id"] not in attached_note_ids + ] + + +def _matching_highlight_index( + clippings: list[dict[str, Any]], note_index: int, note: dict[str, Any] +) -> int | None: + note_location = _range_start(note.get("location")) + if note_location is not None: + for index, clipping in enumerate(clippings): + if clipping["kind"] != "highlight" or clipping["title"] != note["title"]: + continue + highlight_range = _number_range(clipping.get("location")) + if ( + highlight_range + and highlight_range[0] <= note_location <= highlight_range[1] + ): + return index + + note_page = _range_start(note.get("page")) + if note_page is None: + return None + for index in range(note_index - 1, -1, -1): + clipping = clippings[index] + if clipping["kind"] != "highlight" or clipping["title"] != note["title"]: + continue + highlight_page = _number_range(clipping.get("page")) + if highlight_page and highlight_page[0] <= note_page <= highlight_page[1]: + return index + return None + + +def _range_start(value: str | None) -> int | None: + number_range = _number_range(value) + return number_range[0] if number_range else None + + +def _number_range(value: str | None) -> tuple[int, int] | None: + if not value: + return None + numbers = [int(number) for number in re.findall(r"\d+", value)] + if not numbers: + return None + return numbers[0], numbers[-1] diff --git a/src/kindle_zotero_importer/matcher.py b/src/kindle_zotero_importer/matcher.py new file mode 100644 index 0000000..20b9069 --- /dev/null +++ b/src/kindle_zotero_importer/matcher.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from difflib import SequenceMatcher +import json +import re +import unicodedata +from typing import Any + +from .overrides import resolve_override + + +@dataclass(frozen=True) +class MatchCandidate: + item_id: int + key: str + title: str | None + citation_key: str | None + creators: list[str] + attachment_count: int + score: float + reason: str + + +@dataclass(frozen=True) +class TitleMatch: + clipping_title: str + clipping_count: int + candidates: list[MatchCandidate] + + +def load_json(path: str) -> dict[str, Any]: + with open(path, "r", encoding="utf-8") as file: + return json.load(file) + + +def build_match_report( + clippings_payload: dict[str, Any], + zotero_index: dict[str, Any], + overrides: dict[str, dict[str, Any]] | None = None, + max_candidates: int = 5, +) -> dict[str, Any]: + title_counts = _clipping_title_counts(clippings_payload) + matches = [ + _match_title( + title, + count, + zotero_index["items"], + max_candidates, + overrides.get(title) if overrides else None, + ) + for title, count in sorted( + title_counts.items(), key=lambda item: item[0].lower() + ) + ] + status_counts = _status_counts(matches) + return { + "format": "kindle-zotero-importer.match-report.v1", + "clipping_title_count": len(matches), + "matched_title_count": status_counts["matched"], + "ambiguous_title_count": status_counts["ambiguous"], + "unmatched_title_count": status_counts["unmatched"], + "matches": [ + { + "clipping_title": match.clipping_title, + "clipping_count": match.clipping_count, + "status": _match_status(match), + "override": overrides.get(match.clipping_title) if overrides else None, + "candidates": [asdict(candidate) for candidate in match.candidates], + } + for match in matches + ], + } + + +def _clipping_title_counts(clippings_payload: dict[str, Any]) -> dict[str, int]: + counts: dict[str, int] = {} + for clipping in clippings_payload["clippings"]: + title = clipping["title"] + counts[title] = counts.get(title, 0) + 1 + return counts + + +def _match_title( + clipping_title: str, + count: int, + items: list[dict[str, Any]], + max_candidates: int, + override: dict[str, Any] | None = None, +) -> TitleMatch: + if override: + item, reason = resolve_override(clipping_title, override, items) + if item: + return TitleMatch(clipping_title, count, [_candidate(item, 1.0, reason)]) + return TitleMatch( + clipping_title, + count, + [ + MatchCandidate( + item_id=0, + key="", + title=None, + citation_key=None, + creators=[], + attachment_count=0, + score=0.0, + reason=reason, + ) + ], + ) + + citekey = _extract_citekey(clipping_title) + normalized_clipping_title = _normalize_title(clipping_title) + candidates: list[MatchCandidate] = [] + + for item in items: + item_citekey = item["fields"].get("citationKey") + if citekey and item_citekey and citekey.casefold() == item_citekey.casefold(): + candidates.append(_candidate(item, 1.0, "citationKey")) + continue + + item_title = item.get("title") + if not item_title: + continue + + normalized_item_title = _normalize_title(item_title) + if not normalized_item_title: + continue + + if normalized_item_title == normalized_clipping_title: + candidates.append(_candidate(item, 0.98, "title-exact")) + elif normalized_item_title in normalized_clipping_title: + candidates.append( + _candidate(item, 0.92, "title-contained-in-clipping-title") + ) + elif normalized_clipping_title in normalized_item_title: + candidates.append( + _candidate(item, 0.88, "clipping-title-contained-in-title") + ) + else: + score = SequenceMatcher( + None, normalized_clipping_title, normalized_item_title + ).ratio() + if score >= 0.82: + candidates.append(_candidate(item, round(score, 4), "title-fuzzy")) + + candidates.sort( + key=lambda candidate: (candidate.score, candidate.attachment_count), + reverse=True, + ) + return TitleMatch(clipping_title, count, candidates[:max_candidates]) + + +def _candidate(item: dict[str, Any], score: float, reason: str) -> MatchCandidate: + return MatchCandidate( + item_id=item["item_id"], + key=item["key"], + title=item.get("title"), + citation_key=item["fields"].get("citationKey"), + creators=item.get("creators", []), + attachment_count=len(item.get("attachments", [])), + score=score, + reason=reason, + ) + + +def _extract_citekey(title: str) -> str | None: + stripped = title.strip() + if re.fullmatch(r"@?[A-Za-z][A-Za-z0-9_:\-.]+", stripped): + return stripped.removeprefix("@") + + match = re.search(r"(?:^|[\s\[(])@([A-Za-z][A-Za-z0-9_:\-.]+)(?:$|[\s\])])", title) + if match: + return match.group(1) + + match = re.match(r"^([A-Za-z][A-Za-z'\-]+)_([0-9]{4})(?:_|\b)", stripped) + if match: + return f"{match.group(1)}{match.group(2)}" + return None + + +def _normalize_title(value: str) -> str: + value = unicodedata.normalize("NFKD", value) + value = "".join( + character for character in value if not unicodedata.combining(character) + ) + value = value.casefold() + value = re.sub(r"\([^)]*\)", " ", value) + value = re.sub(r"\bby\b.*$", " ", value) + value = re.sub(r"\bz-lib\.org\b", " ", value) + value = re.sub(r"[^a-z0-9]+", " ", value) + return " ".join(value.split()) + + +def _status_counts(matches: list[TitleMatch]) -> dict[str, int]: + counts = {"matched": 0, "ambiguous": 0, "unmatched": 0} + for match in matches: + counts[_match_status(match)] += 1 + return counts + + +def _match_status(match: TitleMatch) -> str: + if not match.candidates: + return "unmatched" + if len(match.candidates) == 1: + return "matched" + if match.candidates[0].score > match.candidates[1].score: + return "matched" + return "ambiguous" diff --git a/src/kindle_zotero_importer/mismatch_review.py b/src/kindle_zotero_importer/mismatch_review.py new file mode 100644 index 0000000..bfa2104 --- /dev/null +++ b/src/kindle_zotero_importer/mismatch_review.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from collections import defaultdict +import json +from typing import Any + + +SECTIONS = [ + ( + "Matched Zotero Item But No Attachment", + "matched-title-no-attachment", + "Attach/link a PDF or EPUB to the listed Zotero item, then re-index Zotero.", + ), + ( + "Unmatched Kindle Titles", + "unmatched-title", + "Add one confirmed mapping to match-overrides.json using citation_key, zotero_key, or zotero_item_id.", + ), + ( + "Matched Attachment But Missing File Path", + "pdf-position-missing-path", + "Fix Zotero linked-file/storage path, then re-index Zotero.", + ), + ( + "Attachment Found But Text Position Failed", + "epub-text-not-found", + "Needs looser EPUB text matching or manual review.", + ), + ( + "PDF Text Not Found", + "pdf-text-not-found", + "Needs looser PDF text matching/OCR/page-offset handling.", + ), + ( + "PDF Rectangles Not Found", + "pdf-rects-not-found", + "Needs improved PDF rectangle recovery after text/page match.", + ), +] + + +def build_mismatch_review( + positioned_plan: dict[str, Any], match_report: dict[str, Any] +) -> str: + match_by_title = { + match["clipping_title"]: match for match in match_report["matches"] + } + by_status: dict[str, list[dict[str, Any]]] = defaultdict(list) + for item in positioned_plan["items"]: + if item.get("status") != "positioned": + by_status[item.get("status", "unknown")].append(item) + + lines = [ + "# Mismatch Review", + "", + "Persistent rule: confirmed title matches go in `match-overrides.json`, not `match-overrides.generated.json`. The generated file can be replaced at any time.", + "", + ] + for heading, status, instruction in SECTIONS: + summaries = _title_summaries(by_status.get(status, []), match_by_title) + lines += [ + f"## {heading}", + "", + f"Status: `{status}`", + f"Clippings: {len(by_status.get(status, []))}", + f"Unique titles: {len(summaries)}", + f"Action: {instruction}", + "", + ] + for summary in summaries[:30]: + lines += _summary_lines(status, summary) + if len(summaries) > 30: + lines += [f"_Showing first 30 of {len(summaries)} unique titles._", ""] + + return "\n".join(lines).rstrip() + "\n" + + +def _title_summaries( + items: list[dict[str, Any]], match_by_title: dict[str, dict[str, Any]] +) -> list[dict[str, Any]]: + summaries: dict[str, dict[str, Any]] = {} + for item in items: + clipping = item["clipping"] + title = clipping["title"] + summary = summaries.setdefault( + title, + { + "title": title, + "count": 0, + "kinds": defaultdict(int), + "examples": [], + "zotero": item.get("zotero"), + "match": match_by_title.get(title), + }, + ) + summary["count"] += 1 + summary["kinds"][clipping.get("kind")] += 1 + if len(summary["examples"]) < 3: + summary["examples"].append( + { + "kind": clipping.get("kind"), + "page": clipping.get("page"), + "location": clipping.get("location"), + "text": (clipping.get("text") or "")[:180], + } + ) + return sorted( + summaries.values(), + key=lambda summary: (-summary["count"], summary["title"].casefold()), + ) + + +def _summary_lines(status: str, summary: dict[str, Any]) -> list[str]: + match = summary.get("match") or {} + candidates = match.get("candidates") or [] + best = candidates[0] if candidates else {} + zotero = summary.get("zotero") or {} + citation_key = zotero.get("citation_key") or best.get("citation_key") or "" + zotero_item_id = zotero.get("parent_item_id") or best.get("item_id") or "" + zotero_key = best.get("key") or "" + attachment_count = best.get("attachment_count") + expected_attachment_type = zotero.get( + "expected_attachment_type" + ) or _expected_attachment_type(status, summary) + override_resolution = _override_resolution(citation_key, zotero_key, zotero_item_id) + + lines = [f"### {summary['title']}", "", "```yaml"] + lines += [ + f"clipping_title: {_yaml_string(summary['title'])}", + f"status: {status}", + f"clipping_count: {summary['count']}", + "kinds: " + _yaml_inline_map(summary["kinds"]), + f"citation_key: {_yaml_string(citation_key)}", + f"zotero_item_id: {_yaml_value(zotero_item_id)}", + f"zotero_key: {_yaml_string(zotero_key)}", + f"zotero_title: {_yaml_string(zotero.get('parent_title') or best.get('title') or '')}", + f"attachment_count: {_yaml_value(attachment_count)}", + f"expected_attachment_type: {_yaml_string(expected_attachment_type)}", + f"match_score: {_yaml_value(best.get('score'))}", + f"match_reason: {_yaml_string(best.get('reason') or '')}", + ] + attachments = zotero.get("attachment_choices") or [] + if attachments: + lines.append("attachment_choices:") + for attachment in attachments: + lines += [ + f" - attachment_item_id: {attachment.get('item_id')}", + f" attachment_key: {_yaml_string(attachment.get('key') or '')}", + f" attachment_title: {_yaml_string(attachment.get('title') or '')}", + f" content_type: {_yaml_string(attachment.get('content_type') or '')}", + f" path: {_yaml_string(attachment.get('resolved_path') or attachment.get('path') or '')}", + ] + if override_resolution: + lines += [ + "override_entry:", + f" clipping_title: {_yaml_string(summary['title'])}", + " resolution:", + f" {override_resolution[0]}: {_yaml_value(override_resolution[1])}", + ] + if attachments: + lines += [ + ' attachment_key: "PASTE_SELECTED_ATTACHMENT_KEY"', + " attachment_item_id: null", + ] + lines += ["```", ""] + + if candidates: + lines += ["Candidates:"] + for candidate in candidates[:5]: + lines.append( + "- " + f"citation_key: `{candidate.get('citation_key')}`, " + f"zotero_item_id: `{candidate.get('item_id')}`, " + f"zotero_key: `{candidate.get('key')}`, " + f"attachments: `{candidate.get('attachment_count')}`, " + f"score: `{candidate.get('score')}`, " + f"title: {candidate.get('title')}" + ) + lines.append("") + + for example in summary["examples"]: + text = example["text"].replace("\n", " ") + lines.append( + f"- Example: {example['kind']} page `{example['page']}` loc `{example['location']}`: {text}" + ) + lines.append("") + return lines + + +def _override_resolution( + citation_key: str, zotero_key: str, zotero_item_id: str | int +) -> tuple[str, str | int] | None: + if citation_key: + return "citation_key", citation_key + if zotero_key: + return "zotero_key", zotero_key + if zotero_item_id: + return "zotero_item_id", zotero_item_id + return None + + +def _expected_attachment_type(status: str, summary: dict[str, Any]) -> str: + if status.startswith("epub-"): + return "epub" + if status.startswith("pdf-"): + return "pdf" + pages = 0 + locations = 0 + for example in summary["examples"]: + if example.get("page"): + pages += 1 + if example.get("location"): + locations += 1 + if pages and not locations: + return "pdf-preferred" + if locations and not pages: + return "epub-preferred" + return "pdf-or-epub" + + +def _yaml_inline_map(values: dict[str, int]) -> str: + return ( + "{" + + ", ".join(f"{key}: {value}" for key, value in sorted(values.items())) + + "}" + ) + + +def _yaml_value(value: Any) -> str: + if value is None or value == "": + return "null" + if isinstance(value, (int, float)): + return str(value) + return _yaml_string(str(value)) + + +def _yaml_string(value: str) -> str: + return json.dumps(value, ensure_ascii=False) diff --git a/src/kindle_zotero_importer/overrides.py b/src/kindle_zotero_importer/overrides.py new file mode 100644 index 0000000..437d53a --- /dev/null +++ b/src/kindle_zotero_importer/overrides.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + + +OVERRIDES_FORMAT = "kindle-zotero-importer.match-overrides.v1" + + +class OverrideError(ValueError): + pass + + +def load_overrides(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: + if payload.get("format") != OVERRIDES_FORMAT: + raise OverrideError(f"unsupported overrides format: {payload.get('format')}") + + overrides: dict[str, dict[str, Any]] = {} + for entry in payload.get("overrides", []): + clipping_title = entry.get("clipping_title") + if not clipping_title: + raise OverrideError("override missing clipping_title") + + resolution = _clean_resolution(entry.get("resolution") or {}) + if not resolution: + continue + item_fields = [ + key + for key in ("citation_key", "zotero_key", "zotero_item_id") + if key in resolution + ] + if len(item_fields) != 1: + raise OverrideError( + f"override for {clipping_title!r} must contain exactly one item resolution field" + ) + overrides[clipping_title] = resolution + + return overrides + + +def resolve_override( + clipping_title: str, resolution: dict[str, Any], items: list[dict[str, Any]] +) -> tuple[dict[str, Any] | None, str]: + if "citation_key" in resolution: + value = str(resolution["citation_key"]).casefold() + return _find_item( + items, + lambda item: (item["fields"].get("citationKey") or "").casefold() == value, + f"override-citation-key:{resolution['citation_key']}", + clipping_title, + ) + if "zotero_key" in resolution: + value = str(resolution["zotero_key"]).casefold() + return _find_item( + items, + lambda item: item["key"].casefold() == value, + f"override-zotero-key:{resolution['zotero_key']}", + clipping_title, + ) + if "zotero_item_id" in resolution: + value = int(resolution["zotero_item_id"]) + return _find_item( + items, + lambda item: item["item_id"] == value, + f"override-zotero-item-id:{value}", + clipping_title, + ) + raise OverrideError(f"unsupported override resolution for {clipping_title!r}") + + +def generate_override_skeleton(match_report: dict[str, Any]) -> dict[str, Any]: + overrides = [] + for match in match_report.get("matches", []): + if match.get("status") == "matched": + continue + overrides.append( + { + "clipping_title": match["clipping_title"], + "resolution": { + "citation_key": "", + "zotero_key": "", + "zotero_item_id": None, + "attachment_key": "", + "attachment_item_id": None, + }, + "notes": "Fill exactly one item resolution field. Prefer citation_key when available. Optionally add attachment_key or attachment_item_id for ambiguous attachments.", + "candidates": match.get("candidates", []), + } + ) + + return {"format": OVERRIDES_FORMAT, "overrides": overrides} + + +def _clean_resolution(resolution: dict[str, Any]) -> dict[str, Any]: + cleaned: dict[str, Any] = {} + for key in ( + "citation_key", + "zotero_key", + "zotero_item_id", + "attachment_key", + "attachment_item_id", + ): + value = resolution.get(key) + if value is None: + continue + if isinstance(value, str) and not value.strip(): + continue + cleaned[key] = value.strip() if isinstance(value, str) else value + return cleaned + + +def _find_item( + items: list[dict[str, Any]], + predicate: Callable[[dict[str, Any]], bool], + reason: str, + clipping_title: str, +) -> tuple[dict[str, Any] | None, str]: + matches = [item for item in items if predicate(item)] + if len(matches) == 1: + return matches[0], reason + if not matches: + return None, f"override-unresolved:{clipping_title}" + return None, f"override-ambiguous:{clipping_title}" diff --git a/src/kindle_zotero_importer/pdf_position.py b/src/kindle_zotero_importer/pdf_position.py new file mode 100644 index 0000000..8dc5bda --- /dev/null +++ b/src/kindle_zotero_importer/pdf_position.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +import subprocess +import xml.etree.ElementTree as ET +from typing import Any + + +@dataclass(frozen=True) +class PDFWord: + text: str + left: float + top: float + width: float + height: float + start: int + end: int + + +@dataclass(frozen=True) +class PDFPageXML: + index: int + width: float + height: float + words: list[PDFWord] + + +def add_pdf_positions(plan: dict[str, Any]) -> dict[str, Any]: + text_cache: dict[str, list[str]] = {} + xml_cache: dict[tuple[str, int], PDFPageXML] = {} + size_cache: dict[tuple[str, int], tuple[float, float]] = {} + positioned = [] + for item in plan["items"]: + positioned.append(_position_item(item, text_cache, xml_cache, size_cache)) + + status_counts: dict[str, int] = {} + for item in positioned: + status_counts[item["status"]] = status_counts.get(item["status"], 0) + 1 + + updated = dict(plan) + updated["items"] = positioned + updated["status_counts"] = status_counts + return updated + + +def _position_item( + item: dict[str, Any], + text_cache: dict[str, list[str]], + xml_cache: dict[tuple[str, int], PDFPageXML], + size_cache: dict[tuple[str, int], tuple[float, float]], +) -> dict[str, Any]: + if item.get("status") != "ready-for-positioning": + return item + attachment = item.get("zotero", {}).get("attachment") + if not attachment or attachment.get("content_type") != "application/pdf": + return item + if item["clipping"]["kind"] != "highlight" or not item["clipping"].get("text"): + return _with_problem(item, "pdf-position-skipped-non-highlight") + + path = attachment.get("resolved_path") or attachment.get("path") + if not path or path.startswith("attachments:"): + return _with_problem(item, "pdf-position-missing-path") + + try: + text_pages = text_cache.setdefault(path, extract_pdf_text_pages(path)) + page_index = find_pdf_text_page( + text_pages, item["clipping"]["text"], item["clipping"].get("page") + ) + if page_index is None: + return _with_problem(item, "pdf-text-not-found") + page_xml = xml_cache.setdefault( + (path, page_index), extract_pdf_page_xml(path, page_index) + ) + position = find_pdf_rects(path, page_xml, item["clipping"]["text"], size_cache) + except Exception as error: # noqa: BLE001 - preserve failure in plan, do not abort batch + return _with_problem(item, f"pdf-position-error:{error}") + + if not position: + return _with_problem(item, "pdf-rects-not-found") + + updated = dict(item) + annotation = dict(updated["annotation"]) + annotation["position"] = { + "pageIndex": position["pageIndex"], + "rects": position["rects"], + } + annotation["pageLabel"] = item["clipping"].get("page") or str( + position["pageIndex"] + 1 + ) + annotation["sortIndex"] = position.get("sortIndex") + updated["annotation"] = annotation + updated["status"] = "positioned" + return updated + + +def extract_pdf_text_pages(path: str) -> list[str]: + result = subprocess.run( + ["pdftotext", "-enc", "UTF-8", path, "-"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.split("\f") + + +def find_pdf_text_page( + pages: list[str], quote: str, kindle_page: str | None = None +) -> int | None: + normalized_quote, _ = _normalize_with_map(quote) + if not normalized_quote: + return None + page_order = list(range(len(pages))) + if kindle_page and kindle_page.isdigit(): + index = int(kindle_page) - 1 + if 0 <= index < len(pages): + page_order.remove(index) + page_order.insert(0, index) + for index in page_order: + normalized_page, _ = _normalize_with_map(pages[index]) + if normalized_quote in normalized_page: + return index + return None + + +def extract_pdf_page_xml(path: str, page_index: int) -> PDFPageXML: + page_number = page_index + 1 + result = subprocess.run( + [ + "pdftohtml", + "-f", + str(page_number), + "-l", + str(page_number), + "-xml", + "-stdout", + path, + ], + check=True, + capture_output=True, + text=True, + ) + root = ET.fromstring(result.stdout) + page_el = root.find("page") + if page_el is None: + raise ValueError(f"no XML page for PDF page {page_number}") + cursor = 0 + words: list[PDFWord] = [] + for text_el in page_el.findall("text"): + text = "".join(text_el.itertext()).strip() + if not text: + continue + for word in text.split(): + start = cursor + end = start + len(word) + words.append( + PDFWord( + text=word, + left=float(text_el.attrib["left"]), + top=float(text_el.attrib["top"]), + width=float(text_el.attrib["width"]), + height=float(text_el.attrib["height"]), + start=start, + end=end, + ) + ) + cursor = end + 1 + return PDFPageXML( + index=page_index, + width=float(page_el.attrib["width"]), + height=float(page_el.attrib["height"]), + words=words, + ) + + +def find_pdf_rects( + path: str, + page: PDFPageXML, + quote: str, + size_cache: dict[tuple[str, int], tuple[float, float]], +) -> dict[str, Any] | None: + normalized_quote, _ = _normalize_with_map(quote) + text = " ".join(word.text for word in page.words) + normalized_text, text_map = _normalize_with_map(text) + start = normalized_text.find(normalized_quote) + if start < 0: + return None + end = start + len(normalized_quote) + raw_start = text_map[start] + raw_end = text_map[end - 1] + 1 + matched_words = [ + word for word in page.words if word.end >= raw_start and word.start <= raw_end + ] + if not matched_words: + return None + pdf_width, pdf_height = size_cache.setdefault( + (path, page.index), + _pdf_page_size(path, page.index + 1, page.width, page.height), + ) + rects = _word_rects(matched_words, page, pdf_width, pdf_height) + return { + "pageIndex": page.index, + "rects": rects, + "sortIndex": f"{page.index:05d}|{int(rects[0][1]):06d}|{int(rects[0][0]):05d}", + } + + +def _word_rects( + words: list[PDFWord], page: PDFPageXML, pdf_width: float, pdf_height: float +) -> list[list[float]]: + lines: dict[int, list[PDFWord]] = {} + for word in words: + lines.setdefault(round(word.top), []).append(word) + scale_x = pdf_width / page.width + scale_y = pdf_height / page.height + rects = [] + for top in sorted(lines): + line_words = lines[top] + left = min(word.left for word in line_words) + right = max(word.left + word.width for word in line_words) + line_top = min(word.top for word in line_words) + line_bottom = max(word.top + word.height for word in line_words) + rects.append( + [ + round(left * scale_x, 3), + round(pdf_height - line_bottom * scale_y, 3), + round(right * scale_x, 3), + round(pdf_height - line_top * scale_y, 3), + ] + ) + return rects + + +def _pdf_page_size( + path: str, page_number: int, fallback_width: float, fallback_height: float +) -> tuple[float, float]: + result = subprocess.run( + ["pdfinfo", "-f", str(page_number), "-l", str(page_number), path], + check=True, + capture_output=True, + text=True, + ) + match = re.search( + rf"Page\s+{page_number}\s+size:\s+([0-9.]+)\s+x\s+([0-9.]+)\s+pts", + result.stdout, + ) + if not match: + match = re.search( + r"Page size:\s+([0-9.]+)\s+x\s+([0-9.]+)\s+pts", result.stdout + ) + if not match: + return fallback_width, fallback_height + return float(match.group(1)), float(match.group(2)) + + +def _normalize_with_map(text: str) -> tuple[str, list[int]]: + normalized = [] + mapping = [] + last_was_space = False + for index, char in enumerate(text): + if char.isspace(): + if not last_was_space and normalized: + normalized.append(" ") + mapping.append(index) + last_was_space = True + continue + normalized.append(char.casefold()) + mapping.append(index) + last_was_space = False + if normalized and normalized[-1] == " ": + normalized.pop() + mapping.pop() + return "".join(normalized), mapping + + +def _with_problem(item: dict[str, Any], problem: str) -> dict[str, Any]: + updated = dict(item) + updated["status"] = problem + updated["problems"] = [*item.get("problems", []), problem] + return updated diff --git a/src/kindle_zotero_importer/zotero_index.py b/src/kindle_zotero_importer/zotero_index.py new file mode 100644 index 0000000..d1a60be --- /dev/null +++ b/src/kindle_zotero_importer/zotero_index.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from pathlib import Path +import sqlite3 +from typing import Any + + +METADATA_FIELDS = ( + "title", + "publicationTitle", + "shortTitle", + "DOI", + "ISBN", + "date", + "url", + "citationKey", +) + + +@dataclass(frozen=True) +class ZoteroAttachment: + item_id: int + key: str + title: str | None + content_type: str | None + path: str | None + resolved_path: str | None + + +@dataclass(frozen=True) +class ZoteroItem: + item_id: int + key: str + item_type: str + title: str | None + creators: list[str] = field(default_factory=list) + fields: dict[str, str] = field(default_factory=dict) + attachments: list[ZoteroAttachment] = field(default_factory=list) + + +def build_zotero_index(db_path: str, storage_root: str | None = None) -> dict[str, Any]: + db_uri = f"file:{Path(db_path)}?mode=ro&immutable=1" + with sqlite3.connect(db_uri, uri=True) as connection: + connection.row_factory = sqlite3.Row + items = _load_parent_items(connection) + creators = _load_creators(connection) + attachments = _load_attachments(connection, storage_root) + + indexed_items: list[ZoteroItem] = [] + for row in items: + fields = _metadata_for_item(row) + indexed_items.append( + ZoteroItem( + item_id=row["itemID"], + key=row["key"], + item_type=row["typeName"], + title=fields.get("title"), + creators=creators.get(row["itemID"], []), + fields=fields, + attachments=attachments.get(row["itemID"], []), + ) + ) + + return { + "format": "kindle-zotero-importer.zotero-index.v1", + "database": str(db_path), + "count": len(indexed_items), + "attachment_count": sum(len(item.attachments) for item in indexed_items), + "items": [_item_to_dict(item) for item in indexed_items], + } + + +def _load_parent_items(connection: sqlite3.Connection) -> list[sqlite3.Row]: + field_columns = ",\n".join( + f"MAX(CASE WHEN fc.fieldName = '{field_name}' THEN idv.value END) AS {field_name}" + for field_name in METADATA_FIELDS + ) + query = f""" + SELECT + i.itemID, + i.key, + it.typeName, + {field_columns} + FROM items i + JOIN itemTypesCombined it ON it.itemTypeID = i.itemTypeID + LEFT JOIN itemData id ON id.itemID = i.itemID + LEFT JOIN fieldsCombined fc ON fc.fieldID = id.fieldID + LEFT JOIN itemDataValues idv ON idv.valueID = id.valueID + WHERE it.typeName NOT IN ('attachment', 'note', 'annotation') + AND i.itemID NOT IN (SELECT itemID FROM deletedItems) + GROUP BY i.itemID + ORDER BY title COLLATE NOCASE + """ + return list(connection.execute(query)) + + +def _load_creators(connection: sqlite3.Connection) -> dict[int, list[str]]: + rows = connection.execute( + """ + SELECT ic.itemID, c.firstName, c.lastName, c.fieldMode + FROM itemCreators ic + JOIN creators c ON c.creatorID = ic.creatorID + ORDER BY ic.itemID, ic.orderIndex + """ + ) + creators: dict[int, list[str]] = {} + for row in rows: + creators.setdefault(row["itemID"], []).append(_format_creator(row)) + return creators + + +def _load_attachments( + connection: sqlite3.Connection, storage_root: str | None +) -> dict[int, list[ZoteroAttachment]]: + rows = connection.execute( + """ + SELECT + a.parentItemID, + a.itemID, + i.key, + a.contentType, + a.path, + idv.value AS title + FROM itemAttachments a + JOIN items i ON i.itemID = a.itemID + LEFT JOIN itemData id ON id.itemID = a.itemID + LEFT JOIN fieldsCombined fc ON fc.fieldID = id.fieldID AND fc.fieldName = 'title' + LEFT JOIN itemDataValues idv ON idv.valueID = id.valueID + WHERE a.parentItemID IS NOT NULL + AND a.contentType IN ('application/pdf', 'application/epub+zip') + AND a.itemID NOT IN (SELECT itemID FROM deletedItems) + ORDER BY a.parentItemID, title COLLATE NOCASE + """ + ) + attachments: dict[int, list[ZoteroAttachment]] = {} + for row in rows: + attachment = ZoteroAttachment( + item_id=row["itemID"], + key=row["key"], + title=row["title"], + content_type=row["contentType"], + path=row["path"], + resolved_path=_resolve_attachment_path( + row["path"], row["key"], storage_root + ), + ) + attachments.setdefault(row["parentItemID"], []).append(attachment) + return attachments + + +def _metadata_for_item(row: sqlite3.Row) -> dict[str, str]: + return { + field_name: row[field_name] for field_name in METADATA_FIELDS if row[field_name] + } + + +def _format_creator(row: sqlite3.Row) -> str: + first_name = (row["firstName"] or "").strip() + last_name = (row["lastName"] or "").strip() + if row["fieldMode"] == 1: + return last_name or first_name + return " ".join(part for part in (first_name, last_name) if part) + + +def _resolve_attachment_path( + path: str | None, key: str, storage_root: str | None +) -> str | None: + if not path: + return None + if path.startswith("storage:"): + if not storage_root: + return path + return str(Path(storage_root) / key / path.removeprefix("storage:")) + if path.startswith("attachments:"): + return path + return path + + +def _item_to_dict(item: ZoteroItem) -> dict[str, Any]: + data = asdict(item) + data["attachments"] = [asdict(attachment) for attachment in item.attachments] + return data diff --git a/zotero-writer.js b/zotero-writer.js new file mode 100644 index 0000000..4be6cc0 --- /dev/null +++ b/zotero-writer.js @@ -0,0 +1,156 @@ +// Run in Zotero via Tools > Developer > Run JavaScript. +// Set PLAN_PATH to the generated import-plan.final.json path. +// Keep DRY_RUN=true until the preview output looks correct. + +const PLAN_PATH = "/Users/ubd/Library/Mobile Documents/iCloud~md~obsidian/Documents/rhizome/06_projects/UTI/kindle-zotero-importer/import-plan.final.json"; +const DRY_RUN = false; + +const text = await Zotero.File.getContentsAsync(PLAN_PATH); +const plan = JSON.parse(text); + +if (plan.format !== "kindle-zotero-importer.zotero-writer-plan.v1") { + throw new Error(`Unsupported plan format: ${plan.format}`); +} + +const results = { + dryRun: DRY_RUN, + total: plan.annotations.length, + created: 0, + skippedExisting: 0, + updatedComments: 0, + removedDuplicates: 0, + failed: [], + preview: [], +}; + +const existingByAttachment = new Map(); + +function normalizePosition(position) { + if (!position) { + return ""; + } + if (typeof position === "string") { + try { + return JSON.stringify(JSON.parse(position)); + } catch (_error) { + return position; + } + } + return JSON.stringify(position); +} + +function annotationFingerprint(annotation) { + return `${annotation.text || ""}\u0000${normalizePosition(annotation.position)}`; +} + +function existingAnnotationFingerprint(annotation) { + return `${annotation.annotationText || ""}\u0000${normalizePosition(annotation.annotationPosition)}`; +} + +async function getExistingAnnotations(attachment) { + if (!existingByAttachment.has(attachment.id)) { + const annotations = attachment.getAnnotations ? attachment.getAnnotations() : []; + const byFingerprint = new Map(); + + for (const annotation of annotations) { + const fingerprint = existingAnnotationFingerprint(annotation); + const kept = byFingerprint.get(fingerprint); + if (!kept) { + byFingerprint.set(fingerprint, annotation); + continue; + } + + const keptHasComment = Boolean(kept.annotationComment); + const currentHasComment = Boolean(annotation.annotationComment); + const duplicate = keptHasComment || !currentHasComment ? annotation : kept; + const replacement = duplicate === annotation ? kept : annotation; + + byFingerprint.set(fingerprint, replacement); + if (!DRY_RUN) { + await duplicate.eraseTx(); + } + results.removedDuplicates += 1; + } + + existingByAttachment.set(attachment.id, byFingerprint); + } + return existingByAttachment.get(attachment.id); +} + +function mergeComments(existingComment, newComment) { + const seen = new Set(); + const merged = []; + for (const comment of [existingComment, newComment]) { + for (const part of String(comment || "").split(/\n\s*\n/)) { + const cleaned = part.trim(); + if (!cleaned || seen.has(cleaned)) { + continue; + } + seen.add(cleaned); + merged.push(cleaned); + } + } + return merged.join("\n\n"); +} + +for (const entry of plan.annotations) { + try { + const attachment = Zotero.Items.get(entry.attachment_item_id); + if (!attachment) { + throw new Error(`Attachment not found: ${entry.attachment_item_id}`); + } + + const annotation = { + key: Zotero.DataObjectUtilities.generateKey(), + ...entry.annotation, + }; + if (!annotation.sortIndex) { + delete annotation.sortIndex; + } + + const existingAnnotations = await getExistingAnnotations(attachment); + const fingerprint = annotationFingerprint(annotation); + const existingAnnotation = existingAnnotations.get(fingerprint); + if (existingAnnotation) { + const mergedComment = mergeComments(existingAnnotation.annotationComment, annotation.comment); + if (mergedComment && existingAnnotation.saveTx && existingAnnotation.annotationComment !== mergedComment) { + existingAnnotation.annotationComment = mergedComment; + if (!DRY_RUN) { + await existingAnnotation.saveTx(); + } + results.updatedComments += 1; + } + results.skippedExisting += 1; + continue; + } + + if (DRY_RUN) { + results.preview.push({ + clipping_id: entry.clipping_id, + attachment_item_id: entry.attachment_item_id, + type: annotation.type, + text: annotation.text.slice(0, 120), + pageLabel: annotation.pageLabel, + sortIndex: annotation.sortIndex, + position: annotation.position, + }); + continue; + } + + await Zotero.Annotations.saveFromJSON(attachment, annotation); + existingAnnotations.set(fingerprint, { + annotationComment: annotation.comment || "", + annotationPosition: annotation.position, + annotationText: annotation.text || "", + }); + results.created += 1; + } catch (error) { + results.failed.push({ + clipping_id: entry.clipping_id, + attachment_item_id: entry.attachment_item_id, + message: String(error), + }); + } +} + +return JSON.stringify(results, null, 2);