Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ infer = "0.19"

# Hashing
sha2 = "0.10"
md-5 = "0.10"

# Error handling
anyhow = "1.0"
Expand Down
5 changes: 5 additions & 0 deletions migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ mod m20260220_000058_remove_prioritize_scans_setting;
// Add search_title column for accent-insensitive search
mod m20260222_000059_add_search_title;

// Add koreader_hash column for KOReader sync
mod m20260309_000060_add_koreader_hash;

pub struct Migrator;

#[async_trait::async_trait]
Expand Down Expand Up @@ -214,6 +217,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260220_000058_remove_prioritize_scans_setting::Migration),
// Add search_title for accent-insensitive search
Box::new(m20260222_000059_add_search_title::Migration),
// Add koreader_hash for KOReader sync
Box::new(m20260309_000060_add_koreader_hash::Migration),
]
}
}
59 changes: 59 additions & 0 deletions migration/src/m20260309_000060_add_koreader_hash.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Add koreader_hash column to books table (nullable, computed on demand)
manager
.alter_table(
Table::alter()
.table(Books::Table)
.add_column(ColumnDef::new(Alias::new("koreader_hash")).string().null())
.to_owned(),
)
.await?;

// Add index for fast lookup by koreader_hash
manager
.create_index(
Index::create()
.name("idx_books_koreader_hash")
.table(Books::Table)
.col(Alias::new("koreader_hash"))
.to_owned(),
)
.await?;

Ok(())
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_index(
Index::drop()
.name("idx_books_koreader_hash")
.table(Books::Table)
.to_owned(),
)
.await?;

manager
.alter_table(
Table::alter()
.table(Books::Table)
.drop_column(Alias::new("koreader_hash"))
.to_owned(),
)
.await?;

Ok(())
}
}

#[derive(DeriveIden)]
enum Books {
Table,
}
1 change: 1 addition & 0 deletions src/api/routes/koreader/dto/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod progress;
29 changes: 29 additions & 0 deletions src/api/routes/koreader/dto/progress.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};

/// KOReader document progress DTO
///
/// Used for both request and response when syncing reading progress.
/// Field names use snake_case to match KOReader's expected JSON format.
#[derive(Debug, Serialize, Deserialize)]
pub struct DocumentProgressDto {
/// KOReader partial MD5 hash identifying the document
pub document: String,

/// Reading progress as a string (page number for PDF/CBZ, XPath for EPUB)
pub progress: String,

/// Overall progress percentage (0.0 to 1.0)
pub percentage: f64,

/// Device name
pub device: String,

/// Device identifier
pub device_id: String,
}

/// Response for successful authentication
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthorizedDto {
pub authorized: String,
}
25 changes: 25 additions & 0 deletions src/api/routes/koreader/handlers/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//! KOReader authentication handlers

use crate::api::error::ApiError;
use crate::api::extractors::AuthContext;
use crate::api::routes::koreader::dto::progress::AuthorizedDto;
use axum::Json;
use axum::http::StatusCode;

/// POST /koreader/users/create
///
/// Always returns 403 Forbidden. User registration is handled by Codex itself,
/// not through the KOReader sync protocol.
pub async fn create_user() -> StatusCode {
StatusCode::FORBIDDEN
}

/// GET /koreader/users/auth
///
/// Returns 200 with `{"authorized": "OK"}` if the user is authenticated.
/// KOReader uses x-auth-user/x-auth-key headers, which map to Basic Auth in Codex.
pub async fn authorize(_auth: AuthContext) -> Result<Json<AuthorizedDto>, ApiError> {
Ok(Json(AuthorizedDto {
authorized: "OK".to_string(),
}))
}
2 changes: 2 additions & 0 deletions src/api/routes/koreader/handlers/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod auth;
pub mod sync;
205 changes: 205 additions & 0 deletions src/api/routes/koreader/handlers/sync.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
//! KOReader sync progress handlers

use crate::api::error::ApiError;
use crate::api::extractors::{AuthContext, AuthState};
use crate::api::routes::koreader::dto::progress::DocumentProgressDto;
use crate::db::repositories::{BookRepository, ReadProgressRepository};
use axum::Json;
use axum::extract::{Path, State};
use std::sync::Arc;

/// GET /koreader/syncs/progress/{document}
///
/// Get reading progress for a document identified by its KOReader hash.
/// Returns the stored progress if found.
pub async fn get_progress(
State(state): State<Arc<AuthState>>,
auth: AuthContext,
Path(document_hash): Path<String>,
) -> Result<Json<DocumentProgressDto>, ApiError> {
let user_id = auth.user_id;

// Find book by koreader_hash
let books = BookRepository::find_by_koreader_hash(&state.db, &document_hash)
.await
.map_err(|e| ApiError::Internal(format!("Failed to find book: {}", e)))?;

if books.is_empty() {
return Err(ApiError::NotFound(
"No book found with this hash".to_string(),
));
}

if books.len() > 1 {
return Err(ApiError::Conflict(
"Multiple books found with the same hash".to_string(),
));
}

let book = &books[0];

// Get reading progress for this user and book
let progress = ReadProgressRepository::get_by_user_and_book(&state.db, user_id, book.id)
.await
.map_err(|e| ApiError::Internal(format!("Failed to get progress: {}", e)))?;

match progress {
Some(p) => {
// Convert internal progress to KOReader format
// For PDF/CBZ: progress is the page number as a string
// For EPUB: we store page number but KOReader expects DocFragment format
let progress_str = p.current_page.to_string();
let percentage = p
.progress_percentage
.unwrap_or_else(|| p.current_page as f64 / book.page_count.max(1) as f64);

Ok(Json(DocumentProgressDto {
document: document_hash,
progress: progress_str,
percentage,
device: String::new(),
device_id: String::new(),
}))
}
None => Err(ApiError::NotFound(
"No progress found for this book".to_string(),
)),
}
}

/// PUT /koreader/syncs/progress
///
/// Update reading progress for a document identified by its KOReader hash.
pub async fn update_progress(
State(state): State<Arc<AuthState>>,
auth: AuthContext,
Json(request): Json<DocumentProgressDto>,
) -> Result<Json<DocumentProgressDto>, ApiError> {
let user_id = auth.user_id;

// Find book by koreader_hash
let books = BookRepository::find_by_koreader_hash(&state.db, &request.document)
.await
.map_err(|e| ApiError::Internal(format!("Failed to find book: {}", e)))?;

if books.is_empty() {
return Err(ApiError::NotFound(
"No book found with this hash".to_string(),
));
}

if books.len() > 1 {
return Err(ApiError::Conflict(
"Multiple books found with the same hash".to_string(),
));
}

let book = &books[0];

// Parse progress string to page number
// For PDF/CBZ: progress is the page number as a string
// For EPUB: progress is a DocFragment XPath string, extract the index
let current_page = parse_koreader_progress(&request.progress, &book.format);

let completed =
request.percentage >= 0.98 || (book.page_count > 0 && current_page >= book.page_count);

// Update progress
ReadProgressRepository::upsert_with_percentage(
&state.db,
user_id,
book.id,
current_page,
Some(request.percentage),
completed,
)
.await
.map_err(|e| ApiError::Internal(format!("Failed to update progress: {}", e)))?;

Ok(Json(request))
}

/// Parse KOReader progress string into a page number
///
/// For PDF/CBZ (pre-paginated): progress is just a page number string like "42"
/// For EPUB: progress is a DocFragment XPath like "/body/DocFragment[10]/body/div/p[1]/text().0"
/// or a TOC-based format like "#_doc_fragment_44_ c37"
fn parse_koreader_progress(progress: &str, format: &str) -> i32 {
match format {
"epub" => parse_epub_progress(progress),
_ => {
// PDF, CBZ, CBR: progress is a page number
progress.parse::<i32>().unwrap_or(1).max(1)
}
}
}

/// Parse EPUB progress from KOReader format
///
/// Handles two formats:
/// 1. DocFragment[N] (1-based): "/body/DocFragment[10]/body/div/p[1]/text().0"
/// 2. _doc_fragment_N_ (0-based): "#_doc_fragment_44_ c37"
/// 3. Plain number fallback
fn parse_epub_progress(progress: &str) -> i32 {
// Try DocFragment[N] format (1-based index)
if let Some(start) = progress.find("DocFragment[") {
let after = &progress[start + 12..];
if let Some(end) = after.find(']')
&& let Ok(index) = after[..end].parse::<i32>()
{
return index.max(1);
}
}

// Try _doc_fragment_N_ format (0-based index)
if let Some(start) = progress.find("_doc_fragment_") {
let after = &progress[start + 14..];
if let Some(end) = after.find('_')
&& let Ok(index) = after[..end].parse::<i32>()
{
return (index + 1).max(1); // Convert 0-based to 1-based
}
}

// Fallback: try parsing as plain number
progress.parse::<i32>().unwrap_or(1).max(1)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_parse_pdf_progress() {
assert_eq!(parse_koreader_progress("42", "cbz"), 42);
assert_eq!(parse_koreader_progress("1", "pdf"), 1);
assert_eq!(parse_koreader_progress("0", "cbr"), 1); // min 1
assert_eq!(parse_koreader_progress("invalid", "pdf"), 1);
}

#[test]
fn test_parse_epub_doc_fragment() {
assert_eq!(
parse_koreader_progress("/body/DocFragment[10]/body/div/p[1]/text().0", "epub"),
10
);
assert_eq!(parse_koreader_progress("/body/DocFragment[1].0", "epub"), 1);
}

#[test]
fn test_parse_epub_doc_fragment_underscore() {
assert_eq!(
parse_koreader_progress("#_doc_fragment_44_ c37", "epub"),
45 // 0-based 44 -> 1-based 45
);
assert_eq!(
parse_koreader_progress("#_doc_fragment_0_ c0", "epub"),
1 // 0-based 0 -> 1-based 1
);
}

#[test]
fn test_parse_epub_plain_number() {
assert_eq!(parse_koreader_progress("5", "epub"), 5);
}
}
Loading
Loading