Add complete Kobo highlight sync system with NickelMenu integration

- Add text cleaning to remove page reference numbers (e.g., "10After" -> "After")
- Implement cleanHighlightText() function with boundary detection
- Add NickelMenu configuration for UI integration
- Create sync wrapper scripts (with and without FBInk notifications)
- Add installation guides for Kobo deployment
- Include pointer/slice explanation examples for learning

Features:
- Extracts highlights from Kobo SQLite database
- Cleans page references from academic texts
- Computes SHA-256 hashes for deduplication
- POSTs JSON to Flask API endpoint
- Supports ARM static linking for Kobo hardware
- Optional FBInk notifications for user feedback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wes
2025-12-01 21:13:16 -05:00
co-authored by Claude
parent 22602bebf9
commit ff42d5c35c
8 changed files with 514 additions and 25 deletions
+98
View File
@@ -0,0 +1,98 @@
# FBInk Integration Guide
## What is FBInk?
FBInk is a tool for drawing text and images on e-ink displays. It's commonly bundled with KOReader and other Kobo mods.
## Check if FBInk is installed
```bash
ssh root@192.168.88.110
which fbink
# or
fbink --version
```
If not installed, it usually comes with KOReader at `/usr/local/koreader/fbink`
## Basic Usage in Scripts
### Show a message
```bash
fbink -pm "Hello, World!"
```
### Show message at specific position
```bash
fbink -pm -y 10 "Message at row 10"
```
### Clear the screen
```bash
fbink -s
```
### Show without refresh (faster)
```bash
fbink -q -pm "Quick message"
```
## Common Options
- `-pm` - Print centered (middle)
- `-q` - Quiet mode (no verbose output)
- `-y N` - Print at row N
- `-s` - Clear screen
- `-h` - Refresh (full screen)
- `-f` - Flash (for animations)
## Testing FBInk
SSH into your Kobo and try:
```bash
fbink -pm "Testing FBInk!"
sleep 2
fbink -s # Clear it
```
## Enhanced sync_highlights.sh
The `sync_highlights_with_fbink.sh` script shows:
- "Syncing highlights..." while running
- "✓ Synced N highlights!" on success
- "✗ Sync failed! Check log" on error
Auto-clears after 2-3 seconds so it doesn't clutter the screen.
## Installation
Replace your existing sync script:
```bash
scp sync_highlights_with_fbink.sh root@192.168.88.110:/usr/local/bin/sync_highlights.sh
ssh root@192.168.88.110 chmod +x /usr/local/bin/sync_highlights.sh
```
## Custom Messages
You can customize the messages in the script:
```bash
# Change this line:
$FBINK -q -pm -y 10 "✓ Synced $COUNT highlights!"
# To something like:
$FBINK -q -pm -y 10 "📚 $COUNT new highlights saved!"
```
## Advanced: Progress Bar
For future enhancements, FBInk can show progress bars:
```bash
fbink -pm -P 50 # Show 50% progress
```
Could be useful if syncing takes a while!
## Fallback Behavior
The script automatically detects if FBInk is available. If not found, it runs silently (just logs to file). So it works either way!
+85
View File
@@ -0,0 +1,85 @@
# Installing kb_exfiltrator on Kobo
## Prerequisites
1. **NickelMenu** installed on your Kobo (usually comes with KOReader)
2. SSH access to your Kobo
3. Kobo connected to WiFi
## Installation
### 1. Copy files to Kobo
```bash
# Copy the binary
scp kb_exfiltrator-kobo-arm root@192.168.88.110:/usr/local/bin/kb_exfiltrator
# Copy the wrapper script
scp sync_highlights.sh root@192.168.88.110:/usr/local/bin/sync_highlights.sh
# Copy NickelMenu config
scp nickel_menu_config root@192.168.88.110:/mnt/onboard/.adds/nm/kb_exfiltrator
```
### 2. Set permissions via SSH
```bash
ssh root@192.168.88.110
chmod +x /usr/local/bin/kb_exfiltrator
chmod +x /usr/local/bin/sync_highlights.sh
```
### 3. Update the endpoint URL
Edit the script on the Kobo to use your server's IP/hostname:
```bash
vi /usr/local/bin/sync_highlights.sh
# Change: ENDPOINT="http://192.168.88.69:42070/api/highlights"
# To your actual server URL
```
### 4. Restart Kobo
The NickelMenu config is read on boot, so restart your Kobo:
```bash
reboot
```
## Usage
After rebooting, you'll see "Sync Highlights" in:
- Main menu (press the top-left Kobo icon)
- Reader menu (while reading a book)
Tap it to sync your highlights to your server!
## Logs
Check sync logs at: `/mnt/onboard/.adds/kb_exfiltrator.log`
## Manual Usage (via SSH)
```bash
ssh root@192.168.88.110
/usr/local/bin/kb_exfiltrator /mnt/onboard/.kobo/KoboReader.sqlite http://yourserver:42070/api/highlights
```
## Troubleshooting
### Menu item doesn't appear
- Check `/mnt/onboard/.adds/nm/kb_exfiltrator` exists
- Restart Kobo
- Check NickelMenu is installed
### Sync fails
- Check log: `cat /mnt/onboard/.adds/kb_exfiltrator.log`
- Verify WiFi is connected
- Test manually via SSH
- Verify server is running: `curl http://192.168.88.69:42070/`
### "No route to host"
- Kobo WiFi disconnected
- Server IP changed
- Edit `/usr/local/bin/sync_highlights.sh` with correct IP
+9
View File
@@ -0,0 +1,9 @@
# NickelMenu configuration for kb_exfiltrator
# Copy this to: /mnt/onboard/.adds/nm/kb_exfiltrator
# Or append to existing NickelMenu config
menu_item :main :Sync Highlights :cmd_spawn :quiet :exec /usr/local/bin/sync_highlights.sh
menu_item :reader :Sync Highlights :cmd_spawn :quiet :exec /usr/local/bin/sync_highlights.sh
# Alternative: Show output in a dialog
# menu_item :main :Sync Highlights :cmd_output :500:/usr/local/bin/sync_highlights.sh
+28
View File
@@ -0,0 +1,28 @@
const std = @import("std");
pub fn main() !void {
// Example showing the conversion steps
// Step 1: SQLite gives us this type (simplified)
const sqlite_ptr: ?*const u8 = "hello";
// Step 2: Check if it's not null
if (sqlite_ptr != null) {
// Step 3: Cast to sentinel-terminated pointer
// @as(TargetType, value) - explicit type conversion
// [*:0]const u8 - pointer to many u8s, terminated by 0
const sentinel_ptr = @as([*:0]const u8, @ptrCast(sqlite_ptr));
// Step 4: Convert sentinel pointer to slice
// std.mem.span finds the 0 terminator and creates a slice
const slice: []const u8 = std.mem.span(sentinel_ptr);
std.debug.print("Slice: {s}, Length: {}\n", .{slice, slice.len});
}
// The one-liner version from our code:
const text = if (sqlite_ptr != null)
std.mem.span(@as([*:0]const u8, @ptrCast(sqlite_ptr)))
else
"";
}
+169 -25
View File
@@ -3,6 +3,61 @@ const c = @cImport({
@cInclude("sqlite3.h"); @cInclude("sqlite3.h");
}); });
// Helper function to clean highlight text by removing page/margin reference numbers
// Removes patterns like "10After" -> "After" and " 5good" -> " good"
fn cleanHighlightText(allocator: std.mem.Allocator, text: []const u8) ![]const u8 {
var result = std.ArrayList(u8).initCapacity(allocator, 0) catch unreachable;
defer result.deinit(allocator);
const writer = result.writer(allocator);
var i: usize = 0;
while (i < text.len) {
// Check if we're at start of text or after whitespace/newline
const at_boundary = (i == 0) or (text[i - 1] == ' ') or (text[i - 1] == '\n') or (text[i - 1] == '\t');
if (at_boundary and std.ascii.isDigit(text[i])) {
// Count consecutive digits
var digit_end = i;
while (digit_end < text.len and std.ascii.isDigit(text[digit_end])) {
digit_end += 1;
}
// If digits are followed by a letter (like "10After"), skip the digits
if (digit_end < text.len and std.ascii.isAlphabetic(text[digit_end])) {
i = digit_end; // Skip the digits
continue;
}
}
try writer.writeByte(text[i]);
i += 1;
}
return try allocator.dupe(u8, result.items);
}
// Helper function to escape JSON strings
fn escapeJson(allocator: std.mem.Allocator, s: []const u8) ![]const u8 {
var result = std.ArrayList(u8).initCapacity(allocator, 0) catch unreachable;
defer result.deinit(allocator);
const writer = result.writer(allocator);
try writer.writeAll("\"");
for (s) |ch| {
switch (ch) {
'"' => try writer.writeAll("\\\""),
'\\' => try writer.writeAll("\\\\"),
'\n' => try writer.writeAll("\\n"),
'\r' => try writer.writeAll("\\r"),
'\t' => try writer.writeAll("\\t"),
else => try writer.writeByte(ch),
}
}
try writer.writeAll("\"");
return try allocator.dupe(u8, result.items);
}
const Highlight = struct { const Highlight = struct {
text: []const u8, // Owned copy text: []const u8, // Owned copy
date: []const u8, // Owned copy date: []const u8, // Owned copy
@@ -11,9 +66,32 @@ const Highlight = struct {
book_hash: [64]u8, // Stack allocated - no cleanup needed book_hash: [64]u8, // Stack allocated - no cleanup needed
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
// JSON-serializable version (no allocator field)
const JsonHighlight = struct {
text: []const u8,
date: []const u8,
book: []const u8,
text_hash: []const u8, // Slice view of the hash array
book_hash: []const u8, // Slice view of the hash array
};
fn toJson(self: *const Highlight) JsonHighlight {
return JsonHighlight{
.text = self.text,
.date = self.date,
.book = self.book,
.text_hash = &self.text_hash,
.book_hash = &self.book_hash,
};
}
fn init(allocator: std.mem.Allocator, text: []const u8, date: []const u8, book: []const u8, content_id_raw: []const u8) !Highlight { fn init(allocator: std.mem.Allocator, text: []const u8, date: []const u8, book: []const u8, content_id_raw: []const u8) !Highlight {
// Clean the text to remove page reference numbers
const cleaned_text = try cleanHighlightText(allocator, text);
defer allocator.free(cleaned_text);
var highlight = Highlight{ var highlight = Highlight{
.text = try allocator.dupe(u8, text), // Copy from SQLite buffer .text = try allocator.dupe(u8, cleaned_text), // Copy cleaned text
.date = try allocator.dupe(u8, date), // Copy from SQLite buffer .date = try allocator.dupe(u8, date), // Copy from SQLite buffer
.book = try allocator.dupe(u8, book), // Copy from SQLite buffer .book = try allocator.dupe(u8, book), // Copy from SQLite buffer
.text_hash = undefined, .text_hash = undefined,
@@ -21,8 +99,8 @@ const Highlight = struct {
.allocator = allocator, .allocator = allocator,
}; };
// Now compute hashes into the struct fields // Now compute hashes into the struct fields (use cleaned text for hash)
computeHash(text, &highlight.text_hash); computeHash(cleaned_text, &highlight.text_hash);
computeHash(content_id_raw, &highlight.book_hash); computeHash(content_id_raw, &highlight.book_hash);
return highlight; return highlight;
@@ -39,7 +117,11 @@ const Highlight = struct {
std.crypto.hash.sha2.Sha256.hash(data, &hash, .{}); std.crypto.hash.sha2.Sha256.hash(data, &hash, .{});
// Convert 32 bytes to 64 hex characters // Convert 32 bytes to 64 hex characters
_ = std.fmt.bufPrint(out_hex, "{s}", .{std.fmt.fmtSliceHexLower(&hash)}) catch unreachable; const hex_chars = "0123456789abcdef";
for (hash, 0..) |byte, i| {
out_hex[i * 2] = hex_chars[byte >> 4];
out_hex[i * 2 + 1] = hex_chars[byte & 0x0F];
}
} }
}; };
@@ -53,12 +135,14 @@ pub fn main() !void {
defer std.process.argsFree(allocator, args); defer std.process.argsFree(allocator, args);
if (args.len < 2) { if (args.len < 2) {
std.debug.print("Usage: {s} <path-to-kobo-database.sqlite>\n", .{args[0]}); std.debug.print("Usage: {s} <path-to-kobo-database.sqlite> [flask-endpoint-url]\n", .{args[0]});
std.debug.print("Example: {s} /mnt/kobo/.kobo/KoboReader.sqlite\n", .{args[0]}); std.debug.print("Example: {s} /mnt/kobo/.kobo/KoboReader.sqlite\n", .{args[0]});
std.debug.print("Example: {s} /mnt/kobo/.kobo/KoboReader.sqlite https://myserver.com/api/highlights\n", .{args[0]});
return error.MissingArgument; return error.MissingArgument;
} }
const db_path = args[1]; const db_path = args[1];
const endpoint_url = if (args.len >= 3) args[2] else null;
// Open database // Open database
var db: ?*c.sqlite3 = null; var db: ?*c.sqlite3 = null;
@@ -77,7 +161,8 @@ pub fn main() !void {
\\ SUBSTR( \\ SUBSTR(
\\ SUBSTR(ContentID, 40, INSTR(ContentID, '.epub') - 40 + 5), \\ SUBSTR(ContentID, 40, INSTR(ContentID, '.epub') - 40 + 5),
\\ INSTR(SUBSTR(ContentID, 40, INSTR(ContentID, '.epub') - 40 + 5), '/') + 1 \\ INSTR(SUBSTR(ContentID, 40, INSTR(ContentID, '.epub') - 40 + 5), '/') + 1
\\ ) AS Book \\ ) AS Book,
\\ ContentID
\\FROM Bookmark \\FROM Bookmark
\\WHERE Text IS NOT NULL \\WHERE Text IS NOT NULL
\\ORDER BY DateCreated DESC; \\ORDER BY DateCreated DESC;
@@ -90,46 +175,105 @@ pub fn main() !void {
} }
defer _ = c.sqlite3_finalize(stmt); defer _ = c.sqlite3_finalize(stmt);
// Set up buffered stdout writer // Create ArrayList to collect highlights
const stdout_file = std.fs.File{ .handle = std.posix.STDOUT_FILENO }; var highlights = std.ArrayList(Highlight).initCapacity(allocator, 0) catch unreachable;
var stdout_buffer: [4096]u8 = undefined; defer {
var stdout_writer = stdout_file.writer(&stdout_buffer); for (highlights.items) |*highlight| {
const stdout = &stdout_writer.interface; highlight.deinit();
var count: usize = 0; }
highlights.deinit(allocator);
}
// Execute query and print results // Execute query and collect results
while (c.sqlite3_step(stmt) == c.SQLITE_ROW) { while (c.sqlite3_step(stmt) == c.SQLITE_ROW) {
count += 1;
// Get the text (highlight) // Get the text (highlight) - convert C pointer to Zig slice
const text_ptr = c.sqlite3_column_text(stmt, 0); const text_ptr = c.sqlite3_column_text(stmt, 0);
const text = if (text_ptr != null) const text = if (text_ptr != null)
std.mem.span(@as([*:0]const u8, @ptrCast(text_ptr))) std.mem.span(@as([*:0]const u8, @ptrCast(text_ptr)))
else else
""; "";
// Get date // Get date - convert C pointer to Zig slice
const date_ptr = c.sqlite3_column_text(stmt, 1); const date_ptr = c.sqlite3_column_text(stmt, 1);
const date = if (date_ptr != null) const date = if (date_ptr != null)
std.mem.span(@as([*:0]const u8, @ptrCast(date_ptr))) std.mem.span(@as([*:0]const u8, @ptrCast(date_ptr)))
else else
"unknown"; "unknown";
// Get book title // Get book title - convert C pointer to Zig slice
const book_ptr = c.sqlite3_column_text(stmt, 2); const book_ptr = c.sqlite3_column_text(stmt, 2);
const book = if (book_ptr != null) const book = if (book_ptr != null)
std.mem.span(@as([*:0]const u8, @ptrCast(book_ptr))) std.mem.span(@as([*:0]const u8, @ptrCast(book_ptr)))
else else
"unknown"; "unknown";
// Print formatted output // Get raw ContentID for hash - convert C pointer to Zig slice
try stdout.print("\n========== Highlight #{d} ==========\n", .{count}); const content_id_ptr = c.sqlite3_column_text(stmt, 3);
try stdout.print("Date: {s}\n", .{date}); const content_id = if (content_id_ptr != null)
try stdout.print("Book: {s}\n", .{book}); std.mem.span(@as([*:0]const u8, @ptrCast(content_id_ptr)))
try stdout.print("\n{s}\n", .{text}); else
try stdout.print("===================================\n", .{}); "";
// Create Highlight struct (makes owned copies and computes hashes)
const highlight = try Highlight.init(allocator, text, date, book, content_id);
// Add to ArrayList
try highlights.append(allocator, highlight);
} }
try stdout.print("\nTotal highlights extracted: {d}\n", .{count}); std.debug.print("Extracted {d} highlights from database\n", .{highlights.items.len});
try stdout_writer.interface.flush();
// Build JSON manually
var json_buffer = std.ArrayList(u8).initCapacity(allocator, 0) catch unreachable;
defer json_buffer.deinit(allocator);
const writer = json_buffer.writer(allocator);
try writer.writeAll("[");
for (highlights.items, 0..) |*highlight, i| {
if (i > 0) try writer.writeAll(",");
try writer.writeAll("{");
const escaped_text = try escapeJson(allocator, highlight.text);
defer allocator.free(escaped_text);
const escaped_book = try escapeJson(allocator, highlight.book);
defer allocator.free(escaped_book);
try writer.print("\"text\":{s},", .{escaped_text});
try writer.print("\"date\":\"{s}\",", .{highlight.date});
try writer.print("\"book\":{s},", .{escaped_book});
try writer.print("\"text_hash\":\"{s}\",", .{highlight.text_hash});
try writer.print("\"book_hash\":\"{s}\"", .{highlight.book_hash});
try writer.writeAll("}");
}
try writer.writeAll("]");
// If endpoint URL provided, POST the JSON; otherwise just print it
if (endpoint_url) |url| {
std.debug.print("Sending {d} highlights ({d} bytes) to {s}...\n", .{highlights.items.len, json_buffer.items.len, url});
var client: std.http.Client = .{ .allocator = allocator };
defer client.deinit();
const uri = try std.Uri.parse(url);
const result = try client.fetch(.{
.location = .{ .uri = uri },
.method = .POST,
.payload = json_buffer.items,
.headers = .{
.content_type = .{ .override = "application/json" },
},
});
const status_code = @intFromEnum(result.status);
if (status_code >= 200 and status_code < 300) {
std.debug.print("✓ Successfully sent highlights to server (HTTP {})\n", .{status_code});
} else {
std.debug.print("✗ Server returned error: HTTP {}\n", .{status_code});
return error.HttpRequestFailed;
}
} else {
std.debug.print("No endpoint provided. JSON payload ({d} bytes):\n{s}\n", .{json_buffer.items.len, json_buffer.items});
}
} }
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# Sync Kobo highlights to home server
# Place at: /usr/local/bin/sync_highlights.sh
DB="/mnt/onboard/.kobo/KoboReader.sqlite"
ENDPOINT="http://192.168.88.69:42070/api/highlights"
LOGFILE="/mnt/onboard/.adds/kb_exfiltrator.log"
# Create log directory if needed
mkdir -p /mnt/onboard/.adds
# Log with timestamp
echo "=== $(date) ===" >> "$LOGFILE"
# Run exfiltrator and capture output
if /usr/local/bin/kb_exfiltrator "$DB" "$ENDPOINT" >> "$LOGFILE" 2>&1; then
# Success
echo "Success: Highlights synced!" >> "$LOGFILE"
# Optional: Show a notification (requires FBInk or similar)
# fbink -g file=success.png
exit 0
else
# Failure
echo "Error: Sync failed. Check log at .adds/kb_exfiltrator.log" >> "$LOGFILE"
exit 1
fi
+74
View File
@@ -0,0 +1,74 @@
#!/bin/sh
# Sync Kobo highlights to home server with FBInk notifications
# Place at: /usr/local/bin/sync_highlights.sh
DB="/mnt/onboard/.kobo/KoboReader.sqlite"
ENDPOINT="http://192.168.88.69:42070/api/highlights"
LOGFILE="/mnt/onboard/.adds/kb_exfiltrator.log"
# Create log directory if needed
mkdir -p /mnt/onboard/.adds
# Check if FBInk is available
FBINK=""
if command -v fbink >/dev/null 2>&1; then
FBINK="fbink"
elif [ -x /usr/local/bin/fbink ]; then
FBINK="/usr/local/bin/fbink"
elif [ -x /usr/bin/fbink ]; then
FBINK="/usr/bin/fbink"
fi
# Show "Syncing..." message
if [ -n "$FBINK" ]; then
$FBINK -q -pm -y 10 "Syncing highlights..."
fi
# Log with timestamp
echo "=== $(date) ===" >> "$LOGFILE"
# Run exfiltrator and capture output
OUTPUT=$(/usr/local/bin/kb_exfiltrator "$DB" "$ENDPOINT" 2>&1)
EXIT_CODE=$?
# Log the output
echo "$OUTPUT" >> "$LOGFILE"
# Parse the output for stats (if successful)
if [ $EXIT_CODE -eq 0 ]; then
# Try to extract highlight count from output
COUNT=$(echo "$OUTPUT" | grep -o 'Extracted [0-9]* highlights' | grep -o '[0-9]*')
if [ -n "$FBINK" ]; then
if [ -n "$COUNT" ]; then
$FBINK -q -pm -y 10 "✓ Synced $COUNT highlights!"
else
$FBINK -q -pm -y 10 "✓ Highlights synced!"
fi
fi
echo "Success: Highlights synced!" >> "$LOGFILE"
# Clear notification after 2 seconds
sleep 2
if [ -n "$FBINK" ]; then
$FBINK -q -s
fi
exit 0
else
# Failure
if [ -n "$FBINK" ]; then
$FBINK -q -pm -y 10 "✗ Sync failed! Check log"
fi
echo "Error: Sync failed" >> "$LOGFILE"
# Clear notification after 3 seconds
sleep 3
if [ -n "$FBINK" ]; then
$FBINK -q -s
fi
exit 1
fi
+25
View File
@@ -0,0 +1,25 @@
const std = @import("std");
pub fn main() !void {
// Why we need the conversion:
// SQLite stores strings as C-style null-terminated strings
const c_string: [*:0]const u8 = "Hello, World!";
// But Zig functions expect slices (pointer + length)
// For example, this function wants a slice:
printText("Direct string"); // This works - string literals are slices
// Can't do this - type mismatch:
// printText(c_string); // ERROR: expected []const u8, found [*:0]const u8
// Must convert pointer to slice:
const slice = std.mem.span(c_string);
printText(slice); // Now it works!
// Our Highlight.init() is the same - it expects slices, not pointers
}
fn printText(text: []const u8) void {
std.debug.print("Text: {s} (length: {})\n", .{text, text.len});
}