hygg_shared/sync/autosync.rs
1//! Which documents sync *automatically* — the account/device-wide sync
2//! **scope**, orthogonal to [`SyncMode`](super::SyncMode) (which decides *what
3//! content* syncs for a document that participates at all).
4//!
5//! A document's outbound sync is gated by three independent layers, checked in
6//! order:
7//! 1. A master on/off kill switch (each client keeps this in its own config;
8//! `false` = fully serverless). Not modelled here — it gates whether any
9//! sync runs at all.
10//! 2. This [`AutoSyncPolicy`] — the automatic-sync scope, combined with the
11//! per-document opt-in flag and the "looks like a book" signal by
12//! [`should_auto_sync`].
13//! 3. The per-document [`SyncMode`](super::SyncMode) content granularity.
14//!
15//! Manual/explicit sync (the reader's `:sync`, a "Sync now" button) bypasses
16//! layer 2 only — it still honours the master switch and the document's
17//! `SyncMode`.
18
19use std::fmt;
20use std::str::FromStr;
21
22use serde::{Deserialize, Serialize};
23
24/// The automatic-sync scope. Ordered least- to most-inclusive:
25/// `Manual < Books < All`. The default is [`AutoSyncPolicy::Books`], so
26/// book-like documents sync automatically while one-off reports and scratch
27/// text stay on the device until explicitly opted in.
28#[derive(
29 Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default,
30)]
31#[serde(rename_all = "lowercase")]
32pub enum AutoSyncPolicy {
33 /// Only documents the user has explicitly opted in sync automatically — the
34 /// "semi-automatic" mode. Manual sync still works on any document.
35 Manual,
36 /// Documents that look like a book (see [`looks_like_book`]) sync
37 /// automatically, plus any explicit per-document opt-ins. The default.
38 #[default]
39 Books,
40 /// Every document syncs automatically (the historical all-in behavior).
41 All,
42}
43
44impl AutoSyncPolicy {
45 /// The wire / config token: `manual` | `books` | `all`.
46 pub fn as_str(self) -> &'static str {
47 match self {
48 AutoSyncPolicy::Manual => "manual",
49 AutoSyncPolicy::Books => "books",
50 AutoSyncPolicy::All => "all",
51 }
52 }
53
54 /// Parse a stored/wire/config token, falling back to the default
55 /// ([`AutoSyncPolicy::Books`]) for anything unrecognized so a bad value never
56 /// silently disables or over-enables sync. Also accepts the legacy boolean
57 /// `AUTO_SYNC` values so older configs migrate: `true` → `books` (the new
58 /// default) and `false` → `manual` (no automatic sync, still opt-in-able).
59 pub fn from_token_or_default(s: &str) -> AutoSyncPolicy {
60 s.parse().unwrap_or_default()
61 }
62}
63
64impl fmt::Display for AutoSyncPolicy {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 f.write_str(self.as_str())
67 }
68}
69
70impl FromStr for AutoSyncPolicy {
71 type Err = ();
72
73 fn from_str(s: &str) -> Result<Self, Self::Err> {
74 match s.trim().to_ascii_lowercase().as_str() {
75 "all" | "everything" | "full" => Ok(AutoSyncPolicy::All),
76 // Legacy `AUTO_SYNC=true` migrates to the new book-only default.
77 "books" | "book" | "true" => Ok(AutoSyncPolicy::Books),
78 // Legacy `AUTO_SYNC=false` becomes opt-in-only rather than fully off;
79 // the master switch is the true kill switch.
80 "manual" | "optin" | "opt-in" | "off" | "false" | "none" => {
81 Ok(AutoSyncPolicy::Manual)
82 }
83 _ => Err(()),
84 }
85 }
86}
87
88/// Whether a document should sync *automatically* under `policy`, given whether
89/// the user has opted this specific document in and whether it looks like a
90/// book. This is only the automatic-sync gate; the caller applies the master
91/// switch and the per-document [`SyncMode`](super::SyncMode) around it.
92pub fn should_auto_sync(
93 policy: AutoSyncPolicy,
94 opted_in: bool,
95 looks_like_book: bool,
96) -> bool {
97 match policy {
98 AutoSyncPolicy::All => true,
99 AutoSyncPolicy::Books => opted_in || looks_like_book,
100 AutoSyncPolicy::Manual => opted_in,
101 }
102}
103
104/// PDFs with at least this many pages are treated as books; shorter PDFs (a
105/// report, a paper, a slide deck) are not.
106pub const BOOK_MIN_PDF_PAGES: u32 = 40;
107
108/// Reflowable / plain-text documents with at least this many wrapped lines are
109/// treated as books (~30–40 printed pages); shorter ones (a note, an article,
110/// a memo) are not.
111pub const BOOK_MIN_TEXT_LINES: usize = 1500;
112
113/// Dedicated ebook container formats — always a book by construction.
114const EBOOK_FORMATS: &[&str] = &["epub", "mobi", "azw", "azw3", "fb2", "kepub"];
115
116/// Whether a document looks like a book, from cheap signals every client
117/// already has. Deliberately conservative: an ebook container is always a book;
118/// otherwise the document's length has to clear a threshold. Tunable via
119/// [`BOOK_MIN_PDF_PAGES`] / [`BOOK_MIN_TEXT_LINES`].
120///
121/// - `format`: lowercase extension / kind (`epub`, `pdf`, `txt`, `text`, …).
122/// - `total_lines`: wrapped line count of the whole document (0 if unknown).
123/// - `page_count`: page count for paginated formats (PDFs); `None` otherwise.
124pub fn looks_like_book(
125 format: &str,
126 total_lines: usize,
127 page_count: Option<u32>,
128) -> bool {
129 let fmt = format.trim().to_ascii_lowercase();
130 if EBOOK_FORMATS.contains(&fmt.as_str()) {
131 return true;
132 }
133 // A paginated document (PDF) with a known page count: book-length pages make
134 // it a book, and a present-but-short count is a strong "not a book" signal —
135 // don't fall through to the line heuristic (an OCR'd short report can have
136 // many text lines).
137 if let Some(pages) = page_count {
138 return pages >= BOOK_MIN_PDF_PAGES;
139 }
140 // Everything else (plain text, markdown, an unpaginated import, or a PDF
141 // whose page count isn't known here): long-form content clears the line
142 // threshold.
143 total_lines >= BOOK_MIN_TEXT_LINES
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn default_is_books() {
152 assert_eq!(AutoSyncPolicy::default(), AutoSyncPolicy::Books);
153 }
154
155 #[test]
156 fn token_round_trips() {
157 for p in
158 [AutoSyncPolicy::Manual, AutoSyncPolicy::Books, AutoSyncPolicy::All]
159 {
160 assert_eq!(p.as_str().parse::<AutoSyncPolicy>(), Ok(p));
161 }
162 }
163
164 #[test]
165 fn legacy_bool_tokens_migrate() {
166 // `AUTO_SYNC=true` (sync everything) migrates to the book-only default.
167 assert_eq!("true".parse(), Ok(AutoSyncPolicy::Books));
168 // `AUTO_SYNC=false` (no sync) becomes opt-in-only.
169 assert_eq!("false".parse(), Ok(AutoSyncPolicy::Manual));
170 assert_eq!(" OFF ".parse(), Ok(AutoSyncPolicy::Manual));
171 assert_eq!(
172 AutoSyncPolicy::from_token_or_default("nonsense"),
173 AutoSyncPolicy::Books
174 );
175 }
176
177 #[test]
178 fn serde_uses_lowercase_tokens() {
179 assert_eq!(
180 serde_json::to_string(&AutoSyncPolicy::Books).unwrap(),
181 "\"books\""
182 );
183 let back: AutoSyncPolicy = serde_json::from_str("\"manual\"").unwrap();
184 assert_eq!(back, AutoSyncPolicy::Manual);
185 }
186
187 #[test]
188 fn should_auto_sync_matrix() {
189 use AutoSyncPolicy::*;
190 // All: everything, regardless of opt-in or book-ness.
191 assert!(should_auto_sync(All, false, false));
192 // Books: book-like OR opted in.
193 assert!(should_auto_sync(Books, false, true));
194 assert!(should_auto_sync(Books, true, false));
195 assert!(!should_auto_sync(Books, false, false));
196 // Manual: only opted in.
197 assert!(should_auto_sync(Manual, true, false));
198 assert!(!should_auto_sync(Manual, false, true));
199 }
200
201 #[test]
202 fn ebook_formats_are_always_books() {
203 for fmt in ["epub", "MOBI", "azw3", "fb2", " kepub "] {
204 assert!(looks_like_book(fmt, 0, None), "{fmt} should be a book");
205 }
206 }
207
208 #[test]
209 fn pdf_uses_page_count_when_known() {
210 assert!(looks_like_book("pdf", 10, Some(BOOK_MIN_PDF_PAGES)));
211 assert!(looks_like_book("pdf", 10, Some(500)));
212 // A short PDF is not a book even if OCR produced many lines.
213 assert!(!looks_like_book("pdf", 100_000, Some(8)));
214 }
215
216 #[test]
217 fn text_uses_line_threshold_when_unpaginated() {
218 assert!(looks_like_book("txt", BOOK_MIN_TEXT_LINES, None));
219 assert!(looks_like_book("text", 20_000, None));
220 // A short report / note stays local.
221 assert!(!looks_like_book("txt", 200, None));
222 assert!(!looks_like_book("md", 300, None));
223 // A PDF whose page count isn't known here falls back to line length.
224 assert!(looks_like_book("pdf", 5_000, None));
225 assert!(!looks_like_book("pdf", 300, None));
226 }
227}