use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PushRequest {
#[serde(default)]
pub device_id: Option<String>,
pub ops: Vec<SyncOp>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PushResponse {
pub applied: Vec<String>,
pub skipped: Vec<String>,
pub server_time: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncOp {
pub op_id: String,
pub book_id: String,
#[serde(default)]
pub deleted: bool,
pub updated_at: i64,
#[serde(flatten)]
pub payload: OpPayload,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
pub enum OpPayload {
Progress(ProgressData),
Bookmark(BookmarkData),
Highlight(HighlightData),
Note(NoteData),
ReadingTime(ReadingTimeData),
ReadingDay(ReadingDayData),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressData {
#[serde(default)]
pub offset: u64,
#[serde(default)]
pub total_lines: u64,
#[serde(default)]
pub percentage: f64,
pub viewport_offset: Option<u64>,
pub cursor_y: Option<u64>,
pub page: Option<u32>,
pub line_in_page: Option<u64>,
#[serde(default)]
pub word_offset: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BookmarkData {
pub mark: String,
#[serde(default)]
pub line: u64,
#[serde(default)]
pub col: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HighlightData {
#[serde(default)]
pub start_offset: u64,
#[serde(default)]
pub end_offset: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NoteData {
pub id: String,
#[serde(default)]
pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadingTimeData {
#[serde(default)]
pub seconds: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadingDayData {
pub day: String,
#[serde(default)]
pub seconds: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn progress_op_round_trips_to_expected_wire_shape() {
let op = SyncOp {
op_id: "op".into(),
book_id: "b".into(),
deleted: false,
updated_at: 42,
payload: OpPayload::Progress(ProgressData {
offset: 10,
total_lines: 100,
percentage: 10.0,
viewport_offset: Some(5),
cursor_y: Some(2),
page: None,
line_in_page: None,
word_offset: None,
}),
};
let value = serde_json::to_value(&op).unwrap();
assert_eq!(value["op_id"], "op");
assert_eq!(value["kind"], "progress");
assert_eq!(value["book_id"], "b");
assert_eq!(value["updated_at"], 42);
assert_eq!(value["data"]["offset"], 10);
assert_eq!(value["data"]["total_lines"], 100);
let back: SyncOp = serde_json::from_value(value).unwrap();
assert_eq!(back.op_id, "op");
assert!(matches!(back.payload, OpPayload::Progress(_)));
}
#[test]
fn note_tombstone_uses_snake_case_kind_and_envelope_deleted() {
let op = SyncOp {
op_id: "op".into(),
book_id: "b".into(),
deleted: true,
updated_at: 200,
payload: OpPayload::Note(NoteData {
id: "id1".into(),
body: String::new(),
line: None,
created_at: Some(1),
}),
};
let value = serde_json::to_value(&op).unwrap();
assert_eq!(value["kind"], "note");
assert_eq!(value["deleted"], true);
assert_eq!(value["data"]["id"], "id1");
assert!(value["data"].get("line").is_none());
}
#[test]
fn reading_time_kind_is_snake_case() {
let op = SyncOp {
op_id: "op".into(),
book_id: "b".into(),
deleted: false,
updated_at: 42,
payload: OpPayload::ReadingTime(ReadingTimeData { seconds: 1234 }),
};
let value = serde_json::to_value(&op).unwrap();
assert_eq!(value["kind"], "reading_time");
assert_eq!(value["data"]["seconds"], 1234);
}
#[test]
fn unknown_kind_is_a_clean_deserialize_error() {
let value = json!({
"op_id": "o", "book_id": "b", "updated_at": 1,
"kind": "telepathy", "data": {}
});
assert!(serde_json::from_value::<SyncOp>(value).is_err());
}
}