Skip to main content

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}