diff --git a/.gitignore b/.gitignore index f26d334..84e49a8 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ kb_exfiltrator-kobo-arm local-dev.sqlite *.sqlite *.db +*.sqlite-shm +*.sqlite-wal # Editor/IDE files .vscode/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9f422c4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A statically-linked Zig binary that extracts highlights from Kobo e-reader SQLite databases. The project builds for both x86_64 (development) and ARM (target Kobo device). + +## Build Commands + +### Local development (x86_64) +```bash +zig build +./zig-out/bin/kb_exfiltrator local-dev.sqlite +``` + +### Run with arguments +```bash +zig build run -- path/to/database.sqlite +``` + +### Testing +```bash +zig build test +``` + +### For Kobo (ARM target) +```bash +./build-kobo.sh +# or manually: +zig build -Dtarget=arm-linux-musleabihf -Doptimize=ReleaseSmall +``` + +The ARM build creates `kb_exfiltrator-kobo-arm` in the project root. + +## Architecture + +### Module Structure + +The project uses Zig's module system with two distinct modules: + +- **kb_exfiltrator module** (`src/root.zig`): Library module exposing reusable functionality +- **Executable module** (`src/main.zig`): CLI entry point that imports the library module + +The executable depends on the library module via the imports mechanism in build.zig:87-91. + +### SQLite Integration + +SQLite is vendored as an amalgamation build (vendor/sqlite-amalgamation-3480000/). The build.zig configuration: +- Links SQLite as a C source file (build.zig:87-93) +- Compiles with `SQLITE_THREADSAFE=0` (no threading, smaller binary) +- Compiles with `SQLITE_OMIT_LOAD_EXTENSION` (security) +- Uses `@cImport` in main.zig:2-4 to import C headers + +### Static Linking + +The binary is fully statically linked: +- Uses musl libc for ARM target (`arm-linux-musleabihf`) +- SQLite compiled directly into the binary +- No external dependencies required on target device + +### Database Schema + +The program queries the Kobo `Bookmark` table: +- `Text`: The highlighted text (non-NULL indicates a highlight) +- `Annotation`: User's notes on the highlight (nullable) +- `DateCreated`: Timestamp +- `ContentID`: Book identifier + +Query implementation: src/main.zig:33-42 + +### Build System Details + +The build.zig uses Zig's build graph system: +- Target and optimization are configurable via CLI (`-Dtarget`, `-Doptimize`) +- The executable step (build.zig:60-84) creates the root module and wires dependencies +- Tests are split into module tests and executable tests (build.zig:132-154) +- Both test suites run in parallel when executing `zig build test` + +## Requirements + +- Zig 0.15.2 or later (specified in build.zig.zon:28) diff --git a/src/main.zig b/src/main.zig index 91e85ba..3ca030e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,6 +3,46 @@ const c = @cImport({ @cInclude("sqlite3.h"); }); +const Highlight = struct { + text: []const u8, // Owned copy + date: []const u8, // Owned copy + book: []const u8, // Owned copy + text_hash: [64]u8, // Stack allocated - no cleanup needed + book_hash: [64]u8, // Stack allocated - no cleanup needed + allocator: std.mem.Allocator, + + fn init(allocator: std.mem.Allocator, text: []const u8, date: []const u8, book: []const u8, content_id_raw: []const u8) !Highlight { + var highlight = Highlight{ + .text = try allocator.dupe(u8, text), // Copy from SQLite buffer + .date = try allocator.dupe(u8, date), // Copy from SQLite buffer + .book = try allocator.dupe(u8, book), // Copy from SQLite buffer + .text_hash = undefined, + .book_hash = undefined, + .allocator = allocator, + }; + + // Now compute hashes into the struct fields + computeHash(text, &highlight.text_hash); + computeHash(content_id_raw, &highlight.book_hash); + + return highlight; + } + + fn deinit(self: *Highlight) void { + self.allocator.free(self.text); + self.allocator.free(self.date); + self.allocator.free(self.book); + } + + fn computeHash(data: []const u8, out_hex: *[64]u8) void { + var hash: [32]u8 = undefined; // SHA256 produces 32 bytes + std.crypto.hash.sha2.Sha256.hash(data, &hash, .{}); + + // Convert 32 bytes to 64 hex characters + _ = std.fmt.bufPrint(out_hex, "{s}", .{std.fmt.fmtSliceHexLower(&hash)}) catch unreachable; + } +}; + pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -33,9 +73,11 @@ pub fn main() !void { const query = \\SELECT \\ Text, - \\ Annotation, \\ DateCreated, - \\ ContentID + \\ SUBSTR( + \\ SUBSTR(ContentID, 40, INSTR(ContentID, '.epub') - 40 + 5), + \\ INSTR(SUBSTR(ContentID, 40, INSTR(ContentID, '.epub') - 40 + 5), '/') + 1 + \\ ) AS Book \\FROM Bookmark \\WHERE Text IS NOT NULL \\ORDER BY DateCreated DESC; @@ -66,38 +108,25 @@ pub fn main() !void { else ""; - // Get annotation (user's note, may be NULL) - const annotation_ptr = c.sqlite3_column_text(stmt, 1); - const annotation = if (annotation_ptr != null) - std.mem.span(@as([*:0]const u8, @ptrCast(annotation_ptr))) - else - null; - // Get date - const date_ptr = c.sqlite3_column_text(stmt, 2); + const date_ptr = c.sqlite3_column_text(stmt, 1); const date = if (date_ptr != null) std.mem.span(@as([*:0]const u8, @ptrCast(date_ptr))) else "unknown"; - // Get content ID (book identifier) - const content_ptr = c.sqlite3_column_text(stmt, 3); - const content_id = if (content_ptr != null) - std.mem.span(@as([*:0]const u8, @ptrCast(content_ptr))) + // Get book title + const book_ptr = c.sqlite3_column_text(stmt, 2); + const book = if (book_ptr != null) + std.mem.span(@as([*:0]const u8, @ptrCast(book_ptr))) else "unknown"; // Print formatted output try stdout.print("\n========== Highlight #{d} ==========\n", .{count}); try stdout.print("Date: {s}\n", .{date}); - try stdout.print("Book: {s}\n", .{content_id}); + try stdout.print("Book: {s}\n", .{book}); try stdout.print("\n{s}\n", .{text}); - - if (annotation) |note| { - if (note.len > 0) { - try stdout.print("\nNote: {s}\n", .{note}); - } - } try stdout.print("===================================\n", .{}); }