Syncing the book to a Kindle
2026-08-25
David: ran /clear
David:
Can you write a script to update the copy of the book on my kindle (currently mounted) with the latest version of the epub?
I'll check what's mounted and where the epub lives.
12 tool calls
π§ Bash: ls /Volumes/; ls redacted/*.epub (List volumes and epubs)
β 4 lines, 75 chars β Audacity / Kindle / Macintosh HD
π§ Bash: ls /Volumes/Kindle/; echo ---; ls /Volumes/Kindle/documents/ 2>/dev/null | head -50 (Inspect kindle layout)
β 62 lines, 749 chars
π§ Bash: cd /Volumes/Kindle/documents; ls -la Autoroad "Autoroad (draft)" 2>&1 (Inspect Autoroad dirs)
β 16 lines, 868 chars β Autoroad: / total 976 / drwx------@ 1 drmaciver staff 8192 25 Aug 10:34 .
π§ Bash: which ebook-convert calibredb kindlegen 2>&1; ls /Applications/calibre.app/Contents/MacOSβ¦ (Check for calibre CLI)
β 13 lines, 218 chars
π§ Bash: mkdir -p redacted/scripts && cat > redacted/scripts/update-kindle.py <<'EOF' #!/usr/bβ¦ (Write and run kindle update script)
β 59 lines, 2536 chars
π§ Bash: git add scripts/update-kindle.py && git commit -q -m "$(cat <<'EOF' Add script to updateβ¦ (Commit script)
β 1 lines, 57 chars β 39e7155 Add script to update the book on a mounted Kindle
scripts/update-kindle.py β run it with the Kindle mounted; already run, so the device now has the current book.
- Converts
book-1.epubto MOBI via calibre'sebook-convertinto a temp dir, then overwrites/Volumes/Kindle/documents/Autoroad/Wrong Man, The - Autoroad.mobi. - Same filename deliberately, so the sibling
.sdrfolder (reading position, bookmarks) stays attached. --epuband--kindleflags override the defaults; it exits with a clear message if the Kindle isn't mounted or calibre isn't installed.
Note there's also an empty Autoroad (draft) folder on the device holding only a stale .sdr β I left it alone.
David:
There's something wrong with the way this is being converted to kindle that is causing page turning to break. It sometimes just freezes and refuses to turn. I've seen this with large imported epubs before.
6 tool calls
π§ Bash: unzip -l book-1.epub | sort -k1 -n -r | head -20; echo ---; ls -la book-1.epub (Inspect epub contents)
β 22 lines, 1094 chars
π§ Bash: cd /Volumes/Kindle && cat system/version.txt 2>/dev/null; ls system/ | head -30; echo ---β¦ (Check kindle device info)
β 32 lines, 692 chars
π§ Bash: cd "/Volumes/Kindle/documents/Autoroad" && ls -la *.sdr/ "The Wrong Man_c893d674-24ec-4b5β¦ (Inspect sdr contents)
β 20 lines, 1059 chars
Found the actual cause. Let me rewrite the script.
6 tool calls
π§ Bash: cat > redacted/scripts/update-kindle.py <<'PY' #!/usr/bin/env python3 """Update the copβ¦ (Rewrite script for azw3 and run)
β 20 lines, 1165 chars
π§ Bash: python3 - <<'PY' import pathlib p = pathlib.Path("scripts/update-kindle.py") s = p.read_tβ¦ (Fix AppleDouble handling and rerun)
β 10 lines, 507 chars
π§ Bash: ls -la "/Volumes/Kindle/documents/Autoroad/"; git add -A scripts && git commit -q -m "$(cβ¦ (Verify device state and commit)
β 5 lines, 263 chars β total 832 / drwx------@ 1 drmaciver staff 8192 25 Aug 15:31 . / drwx------ 1 drmaciver staff 16384 25 Aug 09:07 ..
The device folder is now just the one AZW3 file, nothing else. Two things were wrong, and the second was the real culprit:
Stale reader state. The .sdr sidecar held Wrong Man, The - Autoroad.apnx β the page index β dated 10:34, from before my 14:30 overwrite. Same filename, different content, old index. The reader paginates against an index that no longer matches the text, and hangs. There was also a .mbp1.bad_file sitting in there, which is the device's own record that it choked on that state. The script now deletes the .sdr on every update, so the Kindle rebuilds it.
MOBI6. Calibre's .mobi output is the legacy format; your firmware is 5.19.2, which handles AZW3 (KF8) properly. Output switched to .azw3, and the superseded .mobi is removed so you don't get a duplicate entry.
Also swept the macOS ._ AppleDouble files β those show up as phantom books in the library, though they weren't causing the freeze.
The tradeoff: you lose your reading position on each update. That's unavoidable if the page index has to be rebuilt, and the position was already corrupt. --keep-position skips the .sdr deletion if you ever want it, but it reopens the freeze.
Worth confirming the freeze is actually gone before trusting the fix β I've reasoned from the evidence on disk, not tested it on the device. If it recurs on the AZW3, the next suspect is the conversion itself and I'd look at splitting the larger chapters.
David:
Are you able to parse and regenerate the sdr file?
Good question β worth checking against real files rather than from memory. Other books on the device have .sdr folders I can inspect.
8 tool calls
π§ Bash: cd /Volumes/Kindle/documents && find . -name "*.sdr" -maxdepth 3 | head -5; echo ===; finβ¦ (Find sdr sidecar files on device)
β 7 lines, 275 chars
π§ Bash: cd /Volumes/Kindle/documents && find . -name "*.apnx" | head -5; echo "--- state files --β¦ (Find apnx and reader state files)
β 19 lines, 2144 chars
π§ Bash: cd "/Volumes/Kindle/documents/M, Alex/redacted Wizard, The - Alex M.sdr" && for f in *.β¦ (Hexdump apnx and mbs)
β 43 lines, 2905 chars
π§ Bash: /Applications/calibre.app/Contents/MacOS/calibre-debug -c " from calibre.devices.kindle.aβ¦ (Check calibre APNX builder API)
β 2 lines, 154 chars
Yes for the page index; no, meaningfully, for the position. Let me wire up the half that works.
π§ Bash: cat > redacted/scripts/make_apnx.py <<'PY' """Generate an .apnx page index. Run under cβ¦ (Add apnx regeneration and run)
β 8 lines, 472 chars
π§ Bash: ls -la "/Volumes/Kindle/documents/Autoroad/Wrong Man, The - Autoroad.sdr/" && git add -Aβ¦ (Verify sdr and commit)
β 5 lines, 249 chars β total 48 / drwx------ 1 drmaciver staff 8192 25 Aug 15:33 . / drwx------@ 1 drmaciver staff 8192 25 Aug 15:33 ..
Split answer, because .sdr is a folder with two very different things in it.
The .apnx β yes, fully. It's a documented format and I've now wired it in. Looking at a working one from another book on your device, it's a small binary header followed by two JSON blobs and then a flat array of byte offsets:
{"contentGuid":"36d615e3","asin":"a5318a33-β¦","cdeType":"EBOK","fileRevisionId":"1"}
{"asin":"a5318a33-β¦","pageMap":"(1,a,1)"}
0x000008fc 0x000011f8 0x00001af4 β¦ β one offset per page
Calibre ships APNXBuilder, so rather than hand-rolling it I call that. The script now regenerates the index from the AZW3 it just wrote, meaning it's structurally incapable of being stale β which was the failure mode. You get real page numbers back, which you'd lost under the previous commit.
The .mbp1/.mbs β parseable, but regenerating them is the wrong goal. They're an undocumented Amazon key-value serialization. The readable strings show the shape (timer.model, timer.average.calculator.distribution.normal, and fpr β furthest page read β carrying 1511217). I could reverse enough to rewrite that number.
But that number is a byte offset into the book, and this is why it isn't worth doing: when you edit chapter 3, every offset after it shifts by an unknown amount. There's no honest mapping from an old offset to a new one. Restoring the position would mean confidently dropping you at the wrong paragraph β worse than the Kindle putting you at the start and letting you know it did.
The exception is append-only updates. If you're only adding chapters at the end, offsets into existing text don't move, and the position is genuinely valid β that's the case where --keep-position is correct rather than reckless, and it's now the only case where I'd use it. If you expect to update that way often, I can make the script diff the new epub against the last-shipped one and take the safe path automatically when nothing before the append point changed.
David:
Could you look at the text at that index in the file on disk and figure out the corresponding text in the new version? Possibly also looking at e.g. what chapter you're in etc?
Worth saying up front: your actual position is gone β I deleted the .sdr and the old .mobi two turns ago, so there's no old offset and no old text to match against. What I can do is build and validate the mechanism so it works from the next update on. Let me test it against a book on the device that still has a live position.
π§ Bash: cd "/Volumes/Kindle/documents/M, Alex" && ls -la; echo "--- fpr region of mbs ---"; xxd "β¦ (Inspect redacted Wizard book and fpr)
β 13 lines, 826 chars
π§ Bash: cd redacted: private && /Applications/calibre.app/Contents/MacOS/calibre-debug --inspecβ¦ (Unpack redacted Wizard mobi)
β 7 lines, 272 chars
Understood β the lost position isn't a worry, making it not recur is the goal. Continuing.
8 tool calls
π§ Bash: /Applications/calibre.app/Contents/MacOS/calibre-debug --help 2>&1 | grep -iE "inspect-moβ¦ (Check calibre-debug flags)
β 5 lines, 307 chars
π§ Bash: cd redacted: private && cp "/Volumes/Kindle/documents/M, Alex/redacted Wizard, The -β¦ (Inspect mobi correctly)
β 5 lines, 117 chars
π§ Bash: cd redacted: private && find . -maxdepth 2 -type f | head -20; echo ---; find . -maxdepβ¦ (List decompiled output)
β 27 lines, 468 chars
π§ Bash: cd redacted: private && stat -f%z text.html && python3 -c " d=open('text.html','rb').reβ¦ (Read text at fpr offset)
β 3 lines, 670 chars
fpr is a byte offset into the raw MOBI markup, confirmed: 1511217 of 1517859 bytes lands exactly on that book's end-matter TOC β a finished book. So the mapping is real and the remap is feasible. Let me check the project's test setup.
π§ Bash: ls; echo ---; cat pyproject.toml 2>/dev/null | head -30; ls tests scripts 2>/dev/null (Check project python setup)
β 16 lines, 144 chars
π§ Bash: ls tools build; echo ---; find . -maxdepth 2 -name "pyproject.toml" -o -maxdepth 2 -nameβ¦ (Inspect tools and build dirs)
β 7 lines, 45 chars
No Python infra here, so I'll add a self-contained module with tests. Tests first.
π§ Bash: cat > redacted/scripts/test_kindle_position.py <<'PY' """Tests for remapping a Kindle rβ¦ (Write tests)
β 1 lines, 7 chars
π§ Bash: cat > redacted/scripts/kindle_position.py <<'PY' """Carry a Kindle reading position acrβ¦ (Implement module and run tests)
β 19 lines, 861 chars
uv it is β that's what I'm running tests under. One real weakness surfaced: a long anchor breaks whenever anything is inserted just before the position.
π§ Bash: python3 - <<'PY' import pathlib p = pathlib.Path("kindle_position.py") s = p.read_text()β¦ (Add shorter anchor tier and retest)
β 2 lines, 98 chars
All 16 pass. Now validate against real prose rather than synthetic fixtures β git has older versions of the epub.
π§ Bash: cd redacted && git log --oneline -- book-1.epub | head (History of book epub)
β 10 lines, 803 chars
Ideal test case: 5b2c6ef is a dialogue pass scattering small edits across chapters 003β031. That's the hard case.
8 tool calls
π§ Bash: cat >> redacted/scripts/kindle_position.py <<'PY' # --- getting the raw markup out ofβ¦ (Add rawml extraction)
β 2 lines, 98 chars
π§ Bash: cd redacted && SP=redacted: private && mkdir -p $SP && git show 7294aca:book-1.epβ¦ (Build old and new azw3 for validation)
β 11 lines, 543 chars
π§ Bash: cd redacted/scripts && cat > redacted: private <<'PY' """Sample positionsβ¦ (Validate remapping on the real book)
β 6 lines, 563 chars
π§ Bash: cd redacted: private && ls; /Applications/calibre.app/Contents/MacOS/calibre-debug -m oβ¦ (Debug rawml extraction for azw3)
β 6 lines, 155 chars
KF8 names the file differently from MOBI6.
π§ Bash: cd redacted/scripts && python3 - <<'PY' import pathlib p=pathlib.Path("kindle_position.β¦ (Fix extraction and validate)
β 6 lines, 563 chars
π§ Bash: python3 - <<'PY' import pathlib p=pathlib.Path("kindle_position.py"); s=p.read_text() s=sβ¦ (Fix cwd and validate)
β 8 lines, 345 chars
398/399 exact on a real edit. But my "drift" number measures content shift, not error β let me replace it with an actual accuracy check, and fix the mojibake (I'm decoding byte-by-byte).
π§ Bash: python3 - <<'PY' import pathlib p=pathlib.Path("kindle_position.py"); s=p.read_text() s=sβ¦ (Decode UTF-8 properly, retest)
β 2 lines, 98 chars
π§ Bash: cat > redacted: private <<'PY' import bisect, collections, sys from difflib import Sequβ¦ (Validate with a real accuracy metric)
β 1 lines, 338 chars
Slow because the validator re-strips both books on every one of 399 samples. Fine for production (one call), so I'll let it finish in the background.
6 tool calls
π§ Bash: sleep 100; tail -25 redacted: private 2>/dev/null || echo "still running" (Wait and check validation output)
β 1 lines, 431 chars
π§ ToolSearch ({"query": "select:Monitor", "max_results": 1})
β 1 lines, 50 chars
π§ Bash: until [ -s redacted: private ] && grep -q "landings under" redacted: private (Wait for validation to finish)
β 1 lines, 290 chars
While that runs, let me wire the remap into the update script.
π§ Bash: cd redacted && sed -n '1,60p' scripts/update-kindle.py (Review update script)
β 60 lines, 2303 chars
π§ Bash: cat redacted: private (Read validation results)
β 26 lines, 1252 chars
394 exact, 5 fuzzy, none wild. Note the exact rows at 0.77β0.87 aren't errors: the position is right, the next line is what the dialogue pass changed. Now, which sidecar file actually holds the position, and is there a checksum I'd break?
π§ Bash: cd /Volumes/Kindle/documents && for b in "M, Alex/redacted Wizard, The - Alex M" "Hungrβ¦ (Compare sidecar headers across books)
β 7 lines, 542 chars
π§ Bash: cd redacted/scripts && uv run --quiet python -c " import sys, pathlib sys.path.insert(0β¦ (Find which sidecar holds fpr)
β 20 lines, 1091 chars
.mbs holds fpr, .mbp1 never does β consistent across all 20+ books, and the 8-byte header is a constant magic, not a checksum. Safe to rewrite. Wiring it in:
π§ Bash: cat > redacted/scripts/update-kindle.py <<'PY' #!/usr/bin/env python3 """Update the copβ¦ (Rewrite update script with position carrying)
β 2 lines, 98 chars
Now an end-to-end test on the device: stage the old build plus a synthetic position, then run the real script and see whether it lands on the same sentence.
6 tool calls
π§ Bash: uv run --quiet python - <<'PY' import shutil, sys from pathlib import Path sys.path.inserβ¦ (Stage old build with a real position)
β 2 lines, 131 chars β redacted sentence: unpublished prose / staged fpr: 450274
π§ Bash: cd redacted && ./scripts/update-kindle.py 2>&1 | tail -10 (Run the real update with position carrying)
β 5 lines, 308 chars
π§ Bash: cd redacted/scripts && uv run --quiet python -c " import sys; sys.path.insert(0,'.') frβ¦ (Verify carried position on device)
β 16 lines, 857 chars
Works end-to-end. But that staged .mbs was cloned from another book's template, so I'll clear it β the position was synthetic anyway, and I don't want redacted Wizard's reader state on your book.
π§ Bash: rm "/Volumes/Kindle/documents/Autoroad/Wrong Man, The - Autoroad.sdr/Wrong Man, The - Autβ¦ (Clean up and commit)
β 4 lines, 212 chars
Yes, both parts work. The device now has the current book, a fresh page index, and no stale state.
What the offset is. fpr in the .mbs is a byte offset into the book's raw markup. I confirmed it on a book of yours that still had a live position: redacted Wizard's fpr of 1511217, against a 1517859-byte markup, lands exactly on that book's end-matter TOC β a finished book. The container is trivial: a marker byte, a three-byte big-endian length, then the offset in ASCII.
Reading the text there and re-finding it in the new version. That's scripts/kindle_position.py. It strips markup to visible prose while keeping each character's byte offset, then works down four tiers:
- exact β the prose ending at the position is still present, verbatim and unique. Anchors run 300 β 25 characters; short ones are safe because uniqueness across the whole book is the acceptance test. The short tiers matter more than I first assumed: a 300-character anchor breaks if anything is inserted anywhere in the preceding paragraph, which is what a test caught.
- fuzzy β that passage was edited. Find the enclosing chapter by heading, diff old chapter against new, carry the position through the diff.
- chapter β chapter rewritten past recognition (similarity under 0.5); return its opening.
- reset β chapter gone; back to the start rather than inventing a location.
Validated against a real edit, not fixtures: 5b2c6ef, the dialogue pass scattering changes across chapters 003β031, old epub pulled from git. Of 399 sampled positions, 394 exact, 5 fuzzy, none wild. redacted sentence: unpublished prose
Two things to be straight about. Your original position is genuinely gone β I deleted it earlier. And while the format reading is confirmed against every book on the device (position always in .mbs, never .mbp1; the leading 0x1ab126 is a constant, not a checksum I'd be breaking), whether the firmware accepts a sidecar I rewrote can only be settled by ejecting and opening the book. That caveat is written into the module.
I also drop .mbp1 rather than carry it. It holds annotations, presumably offset-keyed too, and I don't know its layout well enough to remap them honestly β so highlights won't survive an update. Same machinery would probably do it if that turns out to matter.