1use std::collections::HashSet;
4use std::io::{BufRead, BufReader, Read, Write};
5use std::net::{SocketAddr, TcpListener, TcpStream, ToSocketAddrs};
6use std::path::{Path, PathBuf};
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use anyhow::{Context, Result, bail};
10use rusqlite::Connection;
11use serde_json::{Value, json};
12
13const EDITOR: &str = include_str!("editor.html");
14const MAX_REQUEST_BYTES: usize = 4 * 1024 * 1024;
15const MAX_SNAPSHOT_BYTES: usize = 64 * 1024 * 1024;
16const APP_ID: &str = "org.haqor";
17const LEGACY_APP_ID: &str = "com.example.haqor";
18
19mod issue_tui;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct AppSyncSettings {
25 pub server_url: String,
26 pub token: String,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct PullGlossOverridesResult {
32 pub total: usize,
34 pub new: usize,
36}
37
38fn xdg_data_home() -> Result<PathBuf> {
39 if let Some(path) = std::env::var_os("XDG_DATA_HOME").filter(|path| !path.is_empty()) {
40 return Ok(PathBuf::from(path));
41 }
42 let home = std::env::var_os("HOME").context("HOME is not set")?;
43 Ok(PathBuf::from(home).join(".local/share"))
44}
45
46fn shared_preferences_path(data_home: &Path, app_id: &str) -> PathBuf {
47 data_home.join(app_id).join("shared_preferences.json")
48}
49
50pub fn read_default_app_sync_settings() -> Result<AppSyncSettings> {
55 read_app_sync_settings_from_data_home(&xdg_data_home()?)
56}
57
58fn read_app_sync_settings_from_data_home(data_home: &Path) -> Result<AppSyncSettings> {
59 let current = shared_preferences_path(data_home, APP_ID);
60 if current.exists() {
61 return read_app_sync_settings(current);
62 }
63 let legacy = shared_preferences_path(data_home, LEGACY_APP_ID);
64 if legacy.exists() {
65 return read_app_sync_settings(legacy);
66 }
67 read_app_sync_settings(current)
68}
69
70struct SyncEndpoint {
71 host: String,
72 port: u16,
73 path: String,
74}
75
76pub fn read_app_sync_settings(path: impl AsRef<Path>) -> Result<AppSyncSettings> {
79 let path = path.as_ref();
80 let raw = std::fs::read_to_string(path)
81 .with_context(|| format!("reading app preferences {}", path.display()))?;
82 let preferences: Value = serde_json::from_str(&raw)
83 .with_context(|| format!("parsing app preferences {}", path.display()))?;
84 let text = |keys: &[&str], label: &str| -> Result<String> {
85 keys.iter()
86 .find_map(|key| preferences.get(*key).and_then(Value::as_str))
87 .map(str::trim)
88 .filter(|value| !value.is_empty())
89 .map(ToOwned::to_owned)
90 .with_context(|| {
91 format!(
92 "{label} is not configured in {}; supply explicit --server and --token instead",
93 path.display()
94 )
95 })
96 };
97 Ok(AppSyncSettings {
98 server_url: text(
99 &[
100 "flutter.progress_sync_server_url",
101 "progress_sync_server_url",
102 ],
103 "sync server URL",
104 )?,
105 token: text(
106 &["flutter.progress_sync_token", "progress_sync_token"],
107 "sync token",
108 )?,
109 })
110}
111
112pub fn pull_gloss_overrides(progress: &Path, overlay: &Path) -> Result<PullGlossOverridesResult> {
117 let corrections = haqor_core::progress_sync::read_gloss_overrides_file(progress)
118 .with_context(|| format!("reading tutor corrections from {}", progress.display()))?;
119 let lexicon_corrections = haqor_core::progress_sync::read_lexicon_entry_overrides_file(
120 progress,
121 )
122 .with_context(|| format!("reading word-info corrections from {}", progress.display()))?;
123 let valid_corrections: Vec<_> = corrections
124 .iter()
125 .filter(|correction| {
126 let valid = !correction.surface.trim().is_empty() && !correction.gloss.trim().is_empty();
127 if !valid {
128 eprintln!(
129 "Skipping invalid synced tutor gloss for surface {}: surface and gloss must not be empty",
130 correction.surface
131 );
132 }
133 valid
134 })
135 .collect();
136 let mut value = haqor_core::lexicon_overlay::load(overlay)?;
137 let word_gloss_rows = value["word_glosses"]
138 .as_array_mut()
139 .context("overlay `word_glosses` must be an array")?;
140 let existing_word_glosses: HashSet<_> = word_gloss_rows
141 .iter()
142 .filter_map(|row| row["surface"].as_str())
143 .collect();
144 let mut new = valid_corrections
145 .iter()
146 .filter(|correction| !existing_word_glosses.contains(correction.surface.as_str()))
147 .count();
148 for correction in &valid_corrections {
149 let row = word_gloss_rows
150 .iter_mut()
151 .find(|row| row["surface"].as_str() == Some(&correction.surface));
152 let row = match row {
153 Some(row) => row,
154 None => {
155 word_gloss_rows
156 .push(json!({"surface": correction.surface, "gloss": correction.gloss}));
157 word_gloss_rows.last_mut().expect("just pushed a row")
158 }
159 };
160 let object = row
161 .as_object_mut()
162 .context("overlay word gloss row must be an object")?;
163 object.insert("gloss".to_string(), Value::String(correction.gloss.clone()));
164 if correction.note.is_empty() {
165 object.remove("note");
166 } else {
167 object.insert("note".to_string(), Value::String(correction.note.clone()));
168 }
169 }
170 for correction in &lexicon_corrections {
171 if !correction.reader_gloss.is_empty() {
172 let row = word_gloss_rows
173 .iter_mut()
174 .find(|row| row["surface"].as_str() == Some(&correction.surface));
175 let row = match row {
176 Some(row) => row,
177 None => {
178 word_gloss_rows.push(json!({"surface": correction.surface}));
179 word_gloss_rows.last_mut().expect("just pushed a row")
180 }
181 };
182 let object = row
183 .as_object_mut()
184 .context("overlay word gloss row must be an object")?;
185 object.insert(
186 "gloss".to_string(),
187 Value::String(correction.reader_gloss.clone()),
188 );
189 }
190 }
191 let rows = value["lexicon_entries"]
192 .as_array_mut()
193 .context("overlay `lexicon_entries` must be an array")?;
194 let existing_lexicon_entries: HashSet<_> = rows
195 .iter()
196 .filter_map(|row| row["surface"].as_str())
197 .collect();
198 new += lexicon_corrections
199 .iter()
200 .filter(|correction| !existing_lexicon_entries.contains(correction.surface.as_str()))
201 .count();
202 for correction in &lexicon_corrections {
203 let row = rows
204 .iter_mut()
205 .find(|row| row["surface"].as_str() == Some(&correction.surface));
206 let row = match row {
207 Some(row) => row,
208 None => {
209 rows.push(json!({"surface": correction.surface}));
210 rows.last_mut().expect("just pushed a row")
211 }
212 };
213 let object = row
214 .as_object_mut()
215 .context("overlay lexicon entry row must be an object")?;
216 object.insert("root".to_string(), Value::String(correction.root.clone()));
217 object.insert("gloss".to_string(), Value::String(correction.gloss.clone()));
218 }
219 haqor_core::lexicon_overlay::save(overlay, &value)?;
220 Ok(PullGlossOverridesResult {
221 total: valid_corrections.len() + lexicon_corrections.len(),
222 new,
223 })
224}
225
226pub fn pull_gloss_overrides_from_server(
229 server_url: &str,
230 token: &str,
231 overlay: &Path,
232) -> Result<PullGlossOverridesResult> {
233 with_remote_progress(server_url, token, "glosses", |progress| {
234 pull_gloss_overrides(progress, overlay)
235 })
236}
237
238pub fn clear_invalid_gloss_overrides_from_server(server_url: &str, token: &str) -> Result<usize> {
243 if token.trim().is_empty() {
244 bail!("--token must not be empty");
245 }
246 let endpoint = parse_sync_endpoint(server_url)?;
247 let snapshot = download_remote_snapshot(&endpoint, token, "synced tutor glosses")?;
248 if !haqor_core::progress_sync::is_sqlite_snapshot(&snapshot) {
249 bail!("sync server returned an invalid progress snapshot");
250 }
251 let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
252 let temporary = std::env::temp_dir().join(format!(
253 "haqor-admin-clear-invalid-glosses-{}-{nonce}.db",
254 std::process::id()
255 ));
256 std::fs::write(&temporary, snapshot)
257 .with_context(|| format!("writing temporary sync snapshot {}", temporary.display()))?;
258 let result = (|| {
259 let count = clear_invalid_gloss_overrides_file(&temporary)?;
260 if count == 0 {
261 return Ok(0);
262 }
263 eprintln!("Clearing {count} invalid synced tutor gloss override(s) on the server");
264 let merged = post_sync_snapshot(&endpoint, token, &std::fs::read(&temporary)?)?;
265 if !haqor_core::progress_sync::is_sqlite_snapshot(&merged) {
266 bail!("sync server returned an invalid progress snapshot");
267 }
268 std::fs::write(&temporary, merged)?;
269 if invalid_gloss_override_count(&temporary)? != 0 {
270 bail!(
271 "the sync server did not retain invalid-gloss tombstones; update and restart haqor-sync-server before retrying"
272 );
273 }
274 Ok(count)
275 })();
276 let _ = std::fs::remove_file(&temporary);
277 result
278}
279
280fn clear_invalid_gloss_overrides_file(progress: &Path) -> Result<usize> {
281 let db = Connection::open(progress)
282 .with_context(|| format!("opening synced progress database {}", progress.display()))?;
283 let exists: bool = db.query_row(
284 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='gloss_overrides')",
285 [],
286 |row| row.get(0),
287 )?;
288 if !exists {
289 return Ok(0);
290 }
291 let has_deleted: bool = db
292 .prepare("PRAGMA table_info(gloss_overrides)")?
293 .query_map([], |row| row.get::<_, String>(1))?
294 .collect::<rusqlite::Result<Vec<_>>>()?
295 .iter()
296 .any(|column| column == "deleted");
297 if !has_deleted {
298 db.execute_batch(
299 "ALTER TABLE gloss_overrides ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0",
300 )?;
301 }
302 let next_epoch: i64 = db.query_row(
303 "SELECT MAX(COALESCE(MAX(updated_epoch) + 1, 0), ?1) FROM gloss_overrides",
304 [SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64],
305 |row| row.get(0),
306 )?;
307 Ok(db.execute(
308 "UPDATE gloss_overrides SET deleted = 1, updated_epoch = ?1
309 WHERE deleted = 0 AND (TRIM(surface) = '' OR TRIM(gloss) = '')",
310 [next_epoch],
311 )?)
312}
313
314fn invalid_gloss_override_count(progress: &Path) -> Result<usize> {
315 let db = Connection::open_with_flags(progress, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
316 let exists: bool = db.query_row(
317 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='gloss_overrides')",
318 [],
319 |row| row.get(0),
320 )?;
321 if !exists {
322 return Ok(0);
323 }
324 let has_deleted: bool = db
325 .prepare("PRAGMA table_info(gloss_overrides)")?
326 .query_map([], |row| row.get::<_, String>(1))?
327 .collect::<rusqlite::Result<Vec<_>>>()?
328 .iter()
329 .any(|column| column == "deleted");
330 let active_filter = if has_deleted { "deleted = 0 AND " } else { "" };
331 let count: i64 = db.query_row(
332 &format!(
333 "SELECT COUNT(*) FROM gloss_overrides WHERE {active_filter}(TRIM(surface) = '' OR TRIM(gloss) = '')"
334 ),
335 [],
336 |row| row.get(0),
337 )?;
338 usize::try_from(count).context("invalid gloss override count exceeds usize")
339}
340
341pub fn pull_issue_reports(progress: &Path, output: &Path) -> Result<usize> {
343 if !haqor_core::progress_sync::has_issue_reports_file(progress)
344 .with_context(|| format!("checking issue-report support in {}", progress.display()))?
345 {
346 bail!(
347 "the synced progress snapshot has no issue_reports table; the running \
348 haqor-sync-server is out of date. Update and restart it, then sync \
349 the app again; reports remain saved on the device"
350 );
351 }
352 let reports = haqor_core::progress_sync::read_issue_reports_file(progress)
353 .with_context(|| format!("reading issue reports from {}", progress.display()))?;
354 let rows = reports
355 .iter()
356 .map(|report| {
357 let context: Value = serde_json::from_str(&report.context_json)
358 .with_context(|| format!("parsing context for issue report {}", report.id))?;
359 Ok(json!({
360 "id": report.id,
361 "type": report.report_type,
362 "note": report.note,
363 "createdEpoch": report.created_epoch,
364 "updatedEpoch": report.updated_epoch,
365 "context": context,
366 }))
367 })
368 .collect::<Result<Vec<_>>>()?;
369 let rendered = serde_json::to_string_pretty(&json!({"issueReports": rows}))? + "\n";
370 let file_name = output
371 .file_name()
372 .and_then(|value| value.to_str())
373 .unwrap_or("issue_reports.json");
374 let temporary = output.with_file_name(format!(".{file_name}.{}.tmp", std::process::id()));
375 std::fs::write(&temporary, rendered.as_bytes())
376 .with_context(|| format!("writing temporary issue log {}", temporary.display()))?;
377 if let Err(error) = std::fs::rename(&temporary, output) {
378 let _ = std::fs::remove_file(&temporary);
379 return Err(error).with_context(|| format!("replacing issue log {}", output.display()));
380 }
381 Ok(reports.len())
382}
383
384pub fn pull_issue_reports_from_server(
387 server_url: &str,
388 token: &str,
389 output: &Path,
390) -> Result<usize> {
391 with_remote_progress(server_url, token, "issues", |progress| {
392 pull_issue_reports(progress, output)
393 })
394}
395
396pub fn review_issue_reports(progress: &Path, output: &Path) -> Result<usize> {
400 let reports = haqor_core::progress_sync::read_issue_reports_file(progress)
401 .with_context(|| format!("reading issue reports from {}", progress.display()))?;
402 issue_tui::review(reports, |action, _current| match action {
403 issue_tui::Action::Pull => {
404 let count = pull_issue_reports(progress, output)?;
405 Ok(issue_tui::ActionResult {
406 reports: haqor_core::progress_sync::read_issue_reports_file(progress)?,
407 resolved: 0,
408 message: format!("Pulled {count} issue report(s) to {}", output.display()),
409 })
410 }
411 issue_tui::Action::Edit { id, note } => {
412 let updated_epoch = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64;
413 let changed = haqor_core::progress_sync::update_issue_report_note_file(
414 progress,
415 &id,
416 ¬e,
417 updated_epoch,
418 )?;
419 if changed == 0 {
420 bail!("issue report {id} is no longer active");
421 }
422 Ok(issue_tui::ActionResult {
423 reports: haqor_core::progress_sync::read_issue_reports_file(progress)?,
424 resolved: 0,
425 message: "Updated issue report note locally".to_string(),
426 })
427 }
428 issue_tui::Action::Sync(selected) => {
429 let updated_epoch = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64;
430 let count = haqor_core::progress_sync::resolve_issue_reports_file(
431 progress,
432 &selected,
433 updated_epoch,
434 )
435 .with_context(|| format!("resolving issue reports in {}", progress.display()))?;
436 Ok(issue_tui::ActionResult {
437 reports: haqor_core::progress_sync::read_issue_reports_file(progress)?,
438 resolved: count,
439 message: format!("Resolved {count} issue report(s) locally"),
440 })
441 }
442 })
443}
444
445pub fn review_issue_reports_from_server(
448 server_url: &str,
449 token: &str,
450 output: &Path,
451) -> Result<usize> {
452 if token.trim().is_empty() {
453 bail!("--token must not be empty");
454 }
455 let endpoint = parse_sync_endpoint(server_url)?;
456 eprintln!(
457 "Downloading app issue reports from {}:{}",
458 endpoint.host, endpoint.port
459 );
460 let snapshot = download_remote_snapshot(&endpoint, token, "app issue reports")?;
461 if !haqor_core::progress_sync::is_sqlite_snapshot(&snapshot) {
462 bail!("sync server returned an invalid progress snapshot");
463 }
464 let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
465 let temporary = std::env::temp_dir().join(format!(
466 "haqor-admin-review-issues-{}-{nonce}.db",
467 std::process::id()
468 ));
469 std::fs::write(&temporary, snapshot)
470 .with_context(|| format!("writing temporary sync snapshot {}", temporary.display()))?;
471 let result = (|| {
472 let reports = haqor_core::progress_sync::read_issue_reports_file(&temporary)?;
473 issue_tui::review(reports, |action, current| match action {
474 issue_tui::Action::Pull => {
475 let snapshot = download_remote_snapshot(&endpoint, token, "app issue reports")?;
476 std::fs::write(&temporary, snapshot)?;
477 let count = pull_issue_reports(&temporary, output)?;
478 Ok(issue_tui::ActionResult {
479 reports: haqor_core::progress_sync::read_issue_reports_file(&temporary)?,
480 resolved: 0,
481 message: format!("Pulled {count} issue report(s) to {}", output.display()),
482 })
483 }
484 issue_tui::Action::Edit { id, note } => {
485 let updated_epoch = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64;
486 let changed = haqor_core::progress_sync::update_issue_report_note_file(
487 &temporary,
488 &id,
489 ¬e,
490 updated_epoch,
491 )?;
492 if changed == 0 {
493 bail!("issue report {id} is no longer active");
494 }
495 let merged = post_sync_snapshot(&endpoint, token, &std::fs::read(&temporary)?)?;
496 if !haqor_core::progress_sync::is_sqlite_snapshot(&merged) {
497 bail!("sync server returned an invalid progress snapshot");
498 }
499 std::fs::write(&temporary, merged)?;
500 Ok(issue_tui::ActionResult {
501 reports: haqor_core::progress_sync::read_issue_reports_file(&temporary)?,
502 resolved: 0,
503 message: "Updated and synced issue report note".to_string(),
504 })
505 }
506 issue_tui::Action::Sync(selected) => {
507 let updated_epoch = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64;
508 haqor_core::progress_sync::resolve_issue_reports_file(
509 &temporary,
510 &selected,
511 updated_epoch,
512 )?;
513 eprintln!(
514 "Syncing {} resolved app issue report(s) back to the server",
515 selected.len()
516 );
517 let merged = post_sync_snapshot(&endpoint, token, &std::fs::read(&temporary)?)?;
518 if !haqor_core::progress_sync::is_sqlite_snapshot(&merged) {
519 bail!("sync server returned an invalid progress snapshot");
520 }
521 std::fs::write(&temporary, merged)?;
522 let merged_reports =
523 haqor_core::progress_sync::read_issue_reports_file(&temporary)?;
524 if selected.iter().any(|id| {
525 current.iter().any(|report| report.id == *id)
526 && merged_reports.iter().any(|report| report.id == *id)
527 }) {
528 bail!(
529 "the sync server did not retain resolved issue reports; update and restart \
530 haqor-sync-server before resolving reports"
531 );
532 }
533 Ok(issue_tui::ActionResult {
534 reports: merged_reports,
535 resolved: selected.len(),
536 message: format!("Synced {} resolved issue report(s)", selected.len()),
537 })
538 }
539 })
540 })();
541 let _ = std::fs::remove_file(&temporary);
542 result
543}
544
545fn download_remote_snapshot(
546 endpoint: &SyncEndpoint,
547 token: &str,
548 purpose: &str,
549) -> Result<Vec<u8>> {
550 eprintln!(
551 "Downloading {purpose} from {}:{}",
552 endpoint.host, endpoint.port
553 );
554 let snapshot = match fetch_sync_snapshot(endpoint, token) {
555 Ok(snapshot) => snapshot,
556 Err(error) if error.to_string().contains("404 Not Found") => {
557 eprintln!(
558 "Sync server does not support snapshot downloads; using its compatible sync route"
559 );
560 post_sync_snapshot(endpoint, token, &empty_progress_snapshot()?)?
561 }
562 Err(error) => return Err(error),
563 };
564 if !haqor_core::progress_sync::is_sqlite_snapshot(&snapshot) {
565 bail!("sync server returned an invalid progress snapshot");
566 }
567 Ok(snapshot)
568}
569
570fn with_remote_progress<T>(
571 server_url: &str,
572 token: &str,
573 purpose: &str,
574 operation: impl FnOnce(&Path) -> Result<T>,
575) -> Result<T> {
576 if token.trim().is_empty() {
577 bail!("--token must not be empty");
578 }
579 let endpoint = parse_sync_endpoint(server_url)?;
580 eprintln!(
581 "Pulling synced {purpose} from {}:{}",
582 endpoint.host, endpoint.port,
583 );
584 let snapshot = match fetch_sync_snapshot(&endpoint, token) {
585 Ok(snapshot) => snapshot,
586 Err(error) if error.to_string().contains("404 Not Found") => {
587 eprintln!(
588 "Sync server does not support snapshot downloads; using its compatible sync route"
589 );
590 let empty_snapshot = empty_progress_snapshot()?;
591 post_sync_snapshot(&endpoint, token, &empty_snapshot)?
592 }
593 Err(error) => return Err(error),
594 };
595 if !haqor_core::progress_sync::is_sqlite_snapshot(&snapshot) {
596 bail!("sync server returned an invalid progress snapshot");
597 }
598 let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
599 let temporary = std::env::temp_dir().join(format!(
600 "haqor-admin-pull-{purpose}-{}-{nonce}.db",
601 std::process::id(),
602 ));
603 std::fs::write(&temporary, snapshot)
604 .with_context(|| format!("writing temporary sync snapshot {}", temporary.display()))?;
605 let result = operation(&temporary);
606 let _ = std::fs::remove_file(&temporary);
607 result
608}
609
610fn parse_sync_endpoint(input: &str) -> Result<SyncEndpoint> {
611 let rest = input
612 .trim()
613 .strip_prefix("http://")
614 .context("sync server must start with http://")?;
615 let (authority, path) = match rest.find('/') {
616 Some(index) if &rest[index..] == "/" => (&rest[..index], "/v1/progress"),
617 Some(index) => (&rest[..index], &rest[index..]),
618 None => (rest, "/v1/progress"),
619 };
620 if authority.is_empty() || authority.contains('@') {
621 bail!("sync server address is invalid");
622 }
623 let (host, port) = match authority.rsplit_once(':') {
624 Some((host, port)) if !host.is_empty() => (
625 host.to_string(),
626 port.parse().context("sync server port is invalid")?,
627 ),
628 _ => (authority.to_string(), 80),
629 };
630 Ok(SyncEndpoint {
631 host,
632 port,
633 path: path.to_string(),
634 })
635}
636
637fn fetch_sync_snapshot(endpoint: &SyncEndpoint, token: &str) -> Result<Vec<u8>> {
638 let address = format!("{}:{}", endpoint.host, endpoint.port);
639 let socket = address
640 .to_socket_addrs()
641 .with_context(|| format!("resolving sync server {}", endpoint.host))?
642 .next()
643 .context("sync server address did not resolve")?;
644 let mut stream = TcpStream::connect_timeout(&socket, Duration::from_secs(10))
645 .context("connecting to sync server")?;
646 let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
647 let _ = stream.set_write_timeout(Some(Duration::from_secs(30)));
648 write!(
649 stream,
650 "GET {} HTTP/1.1\r\nHost: {}\r\nAuthorization: Bearer {}\r\nConnection: close\r\n\r\n",
651 endpoint.path, endpoint.host, token
652 )?;
653
654 let mut reader = BufReader::new(stream);
655 let mut status = String::new();
656 reader.read_line(&mut status)?;
657 if !status.starts_with("HTTP/1.1 200") && !status.starts_with("HTTP/1.0 200") {
658 bail!("sync server returned {}", status.trim());
659 }
660 let mut content_length = None;
661 loop {
662 let mut line = String::new();
663 if reader.read_line(&mut line)? == 0 {
664 bail!("sync server closed the response headers early");
665 }
666 if line == "\r\n" {
667 break;
668 }
669 if let Some((name, value)) = line.split_once(':')
670 && name.eq_ignore_ascii_case("content-length")
671 {
672 content_length = value.trim().parse::<usize>().ok();
673 }
674 }
675 let length = content_length.context("sync server omitted Content-Length")?;
676 if length > MAX_SNAPSHOT_BYTES {
677 bail!("sync server returned an unexpectedly large snapshot");
678 }
679 let mut snapshot = vec![0; length];
680 reader.read_exact(&mut snapshot)?;
681 Ok(snapshot)
682}
683
684fn empty_progress_snapshot() -> Result<Vec<u8>> {
688 let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
689 let temporary = std::env::temp_dir().join(format!(
690 "haqor-admin-empty-progress-{}-{nonce}.db",
691 std::process::id()
692 ));
693 let result = (|| {
694 let db = Connection::open_in_memory()?;
695 db.execute(
696 "ATTACH DATABASE ?1 AS progress",
697 [temporary.to_string_lossy().as_ref()],
698 )?;
699 haqor_core::tutor::init_progress_schema(&db)?;
700 drop(db);
701 std::fs::read(&temporary).context("reading empty progress snapshot")
702 })();
703 let _ = std::fs::remove_file(&temporary);
704 result
705}
706
707fn post_sync_snapshot(endpoint: &SyncEndpoint, token: &str, snapshot: &[u8]) -> Result<Vec<u8>> {
708 let address = format!("{}:{}", endpoint.host, endpoint.port);
709 let socket = address
710 .to_socket_addrs()
711 .with_context(|| format!("resolving sync server {}", endpoint.host))?
712 .next()
713 .context("sync server address did not resolve")?;
714 let mut stream = TcpStream::connect_timeout(&socket, Duration::from_secs(10))
715 .context("connecting to sync server")?;
716 let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
717 let _ = stream.set_write_timeout(Some(Duration::from_secs(30)));
718 write!(
719 stream,
720 "POST {} HTTP/1.1\r\nHost: {}\r\nAuthorization: Bearer {}\r\nContent-Type: application/vnd.sqlite3\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
721 endpoint.path,
722 endpoint.host,
723 token,
724 snapshot.len(),
725 )?;
726 stream.write_all(snapshot)?;
727
728 let mut reader = BufReader::new(stream);
729 let mut status = String::new();
730 reader.read_line(&mut status)?;
731 if !status.starts_with("HTTP/1.1 200") && !status.starts_with("HTTP/1.0 200") {
732 bail!("sync server returned {}", status.trim());
733 }
734 let mut content_length = None;
735 loop {
736 let mut line = String::new();
737 if reader.read_line(&mut line)? == 0 {
738 bail!("sync server closed the response headers early");
739 }
740 if line == "\r\n" {
741 break;
742 }
743 if let Some((name, value)) = line.split_once(':')
744 && name.eq_ignore_ascii_case("content-length")
745 {
746 content_length = value.trim().parse::<usize>().ok();
747 }
748 }
749 let length = content_length.context("sync server omitted Content-Length")?;
750 if length > MAX_SNAPSHOT_BYTES {
751 bail!("sync server returned an unexpectedly large snapshot");
752 }
753 let mut merged = vec![0; length];
754 reader.read_exact(&mut merged)?;
755 Ok(merged)
756}
757
758pub fn serve(bind: SocketAddr, overlay: PathBuf, lexicon: PathBuf, hebrew: PathBuf) -> Result<()> {
759 if !bind.ip().is_loopback() {
760 bail!(
761 "refusing to expose the unauthenticated overlay editor on non-loopback address {bind}"
762 );
763 }
764 haqor_core::lexicon_overlay::load(&overlay)?;
765 read_lexicon(&lexicon)?;
766 read_ambiguous(&hebrew)?;
767 let listener =
768 TcpListener::bind(bind).with_context(|| format!("binding admin server to {bind}"))?;
769 eprintln!("Overlay editor: http://{bind}");
770 eprintln!("Editing: {}", overlay.display());
771 eprintln!("Browsing: {}", lexicon.display());
772 eprintln!("Reviewing: {}", hebrew.display());
773 for stream in listener.incoming() {
774 match stream {
775 Ok(stream) => {
776 let path = overlay.clone();
777 let lexicon = lexicon.clone();
778 let hebrew = hebrew.clone();
779 std::thread::spawn(move || {
780 if let Err(error) = handle(stream, &path, &lexicon, &hebrew) {
781 eprintln!("overlay editor request failed: {error:#}");
782 }
783 });
784 }
785 Err(error) => eprintln!("overlay editor connection failed: {error}"),
786 }
787 }
788 Ok(())
789}
790
791fn handle(mut stream: TcpStream, overlay: &Path, lexicon: &Path, hebrew: &Path) -> Result<()> {
792 let (method, target, body) = read_request(&mut stream)?;
793 match (method.as_str(), target.as_str()) {
794 ("GET", "/") => respond(&mut stream, 200, "text/html; charset=utf-8", EDITOR),
795 ("GET", "/api/overlay") => match std::fs::read_to_string(overlay) {
796 Ok(body) => respond(&mut stream, 200, "application/json; charset=utf-8", &body),
797 Err(error) => error_response(&mut stream, 500, &error.to_string()),
798 },
799 ("GET", "/api/lexicon") => match read_lexicon(lexicon) {
800 Ok(value) => respond(
801 &mut stream,
802 200,
803 "application/json; charset=utf-8",
804 &value.to_string(),
805 ),
806 Err(error) => error_response(&mut stream, 500, &format!("{error:#}")),
807 },
808 ("GET", "/api/ambiguous") => match read_ambiguous(hebrew) {
809 Ok(value) => respond(
810 &mut stream,
811 200,
812 "application/json; charset=utf-8",
813 &value.to_string(),
814 ),
815 Err(error) => error_response(&mut stream, 500, &format!("{error:#}")),
816 },
817 ("PUT", "/api/overlay") => match serde_json::from_slice(&body)
818 .context("request body is not valid JSON")
819 .and_then(|value| haqor_core::lexicon_overlay::save(overlay, &value))
820 {
821 Ok(_) => respond(
822 &mut stream,
823 200,
824 "application/json; charset=utf-8",
825 &json!({"ok": true}).to_string(),
826 ),
827 Err(error) => error_response(&mut stream, 400, &format!("{error:#}")),
828 },
829 _ => error_response(&mut stream, 404, "not found"),
830 }
831}
832
833fn read_ambiguous(path: &Path) -> Result<serde_json::Value> {
834 let db = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
835 .with_context(|| format!("opening Hebrew database {}", path.display()))?;
836 let mut statement = db.prepare(
837 "WITH chosen AS (
838 SELECT surface_id FROM surface
839 WHERE n_candidates > 1 AND lexical_class IS NULL AND language IS NULL
840 ORDER BY occurrences DESC, surface_id LIMIT 500
841 )
842 SELECT s.surface_id, s.text, s.occurrences, a.analysis_id, 'verb',
843 a.root, a.binyan, a.form, a.pgn, a.prefix, a.vav_consecutive,
844 a.obj_suffix, a.attested, '', '', ''
845 FROM chosen c JOIN surface s USING(surface_id) JOIN analyses a USING(surface_id)
846 UNION ALL
847 SELECT s.surface_id, s.text, s.occurrences, n.analysis_id, 'noun',
848 '', '', '', '', n.prefix, 0, '', 1, n.stem, n.kind, n.label
849 FROM chosen c JOIN surface s USING(surface_id) JOIN noun_analyses n USING(surface_id)
850 ORDER BY 3 DESC, 1, 5 DESC, 4",
851 )?;
852 let mut surfaces: Vec<serde_json::Value> = Vec::new();
853 let mut identities = HashSet::new();
854 let mapped = statement.query_map([], |row| {
855 Ok((
856 row.get::<_, i64>(0)?,
857 row.get::<_, String>(1)?,
858 row.get::<_, i64>(2)?,
859 json!({"analysis_id": row.get::<_, i64>(3)?, "analysis_type": row.get::<_, String>(4)?,
860 "root": row.get::<_, String>(5)?, "binyan": row.get::<_, String>(6)?,
861 "form": row.get::<_, String>(7)?, "pgn": row.get::<_, String>(8)?,
862 "prefix": row.get::<_, String>(9)?, "vav_consecutive": row.get::<_, i64>(10)? != 0,
863 "obj_suffix": row.get::<_, String>(11)?, "attested": row.get::<_, i64>(12)? != 0,
864 "stem": row.get::<_, String>(13)?, "kind": row.get::<_, String>(14)?,
865 "label": row.get::<_, String>(15)?}),
866 ))
867 })?;
868 for row in mapped {
869 let (id, text, occurrences, analysis) = row?;
870 if surfaces.last().and_then(|v| v["surface_id"].as_i64()) != Some(id) {
871 surfaces.push(json!({"surface_id": id, "surface": text, "occurrences": occurrences, "analyses": []}));
872 identities.clear();
873 }
874 let fields: &[&str] = if analysis["analysis_type"] == "noun" {
875 &["analysis_type", "stem", "kind", "label", "prefix"]
876 } else {
877 &[
878 "analysis_type",
879 "root",
880 "binyan",
881 "form",
882 "pgn",
883 "prefix",
884 "vav_consecutive",
885 "obj_suffix",
886 ]
887 };
888 let identity = fields
889 .iter()
890 .map(|field| analysis[*field].to_string())
891 .collect::<Vec<_>>()
892 .join("\u{1f}");
893 if !identities.insert(identity) {
894 continue;
895 }
896 surfaces.last_mut().unwrap()["analyses"]
897 .as_array_mut()
898 .unwrap()
899 .push(analysis);
900 }
901 Ok(json!({"surfaces": surfaces}))
902}
903
904fn read_lexicon(path: &Path) -> Result<serde_json::Value> {
905 let db = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
906 .with_context(|| format!("opening lexicon database {}", path.display()))?;
907 let mut rows = Vec::new();
908 {
909 let mut statement = db.prepare(
910 "SELECT 'BDB', bdb_id, word, root, gloss FROM bdb
911 WHERE word IS NOT NULL AND word <> '' AND gloss IS NOT NULL AND gloss <> ''
912 UNION ALL
913 SELECT 'Strong', 'H' || strong, word, '', gloss FROM english
914 WHERE word IS NOT NULL AND word <> '' AND gloss IS NOT NULL AND gloss <> ''
915 ORDER BY 3, 1, 2",
916 )?;
917 let mapped = statement.query_map([], |row| {
918 Ok(json!({
919 "source": row.get::<_, String>(0)?,
920 "id": row.get::<_, String>(1)?,
921 "surface": row.get::<_, String>(2)?,
922 "root": row.get::<_, String>(3)?,
923 "gloss": row.get::<_, String>(4)?,
924 }))
925 })?;
926 for row in mapped {
927 rows.push(row?);
928 }
929 }
930 Ok(json!({"entries": rows}))
931}
932
933fn read_request(stream: &mut TcpStream) -> Result<(String, String, Vec<u8>)> {
934 let mut data = Vec::new();
935 let mut chunk = [0_u8; 8192];
936 let header_end;
937 loop {
938 let read = stream.read(&mut chunk)?;
939 if read == 0 {
940 bail!("connection closed before request headers");
941 }
942 data.extend_from_slice(&chunk[..read]);
943 if data.len() > MAX_REQUEST_BYTES {
944 bail!("request exceeds {MAX_REQUEST_BYTES} bytes");
945 }
946 if let Some(end) = data.windows(4).position(|w| w == b"\r\n\r\n") {
947 header_end = end + 4;
948 break;
949 }
950 }
951 let headers =
952 std::str::from_utf8(&data[..header_end]).context("request headers are not UTF-8")?;
953 let mut lines = headers.split("\r\n");
954 let mut request_line = lines.next().unwrap_or_default().split_whitespace();
955 let method = request_line
956 .next()
957 .context("missing HTTP method")?
958 .to_string();
959 let target = request_line
960 .next()
961 .context("missing request target")?
962 .split('?')
963 .next()
964 .unwrap()
965 .to_string();
966 let content_length = lines
967 .filter_map(|line| line.split_once(':'))
968 .find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
969 .map(|(_, value)| value.trim().parse::<usize>())
970 .transpose()
971 .context("invalid Content-Length")?
972 .unwrap_or(0);
973 if header_end + content_length > MAX_REQUEST_BYTES {
974 bail!("request exceeds {MAX_REQUEST_BYTES} bytes");
975 }
976 while data.len() < header_end + content_length {
977 let read = stream.read(&mut chunk)?;
978 if read == 0 {
979 bail!("connection closed before request body");
980 }
981 data.extend_from_slice(&chunk[..read]);
982 }
983 Ok((
984 method,
985 target,
986 data[header_end..header_end + content_length].to_vec(),
987 ))
988}
989
990fn error_response(stream: &mut TcpStream, status: u16, message: &str) -> Result<()> {
991 respond(
992 stream,
993 status,
994 "application/json; charset=utf-8",
995 &json!({"ok": false, "error": message}).to_string(),
996 )
997}
998
999fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &str) -> Result<()> {
1000 let reason = match status {
1001 200 => "OK",
1002 400 => "Bad Request",
1003 404 => "Not Found",
1004 _ => "Internal Server Error",
1005 };
1006 write!(
1007 stream,
1008 "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n",
1009 body.len()
1010 )?;
1011 stream.write_all(body.as_bytes())?;
1012 Ok(())
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017 use super::*;
1018
1019 #[test]
1020 fn editor_exposes_structured_overlay_controls() {
1021 assert!(EDITOR.contains("Add lexicon entry"));
1022 assert!(EDITOR.contains("Add word gloss"));
1023 assert!(EDITOR.contains("Proper name"));
1024 assert!(EDITOR.contains("Imported glosses"));
1025 assert!(EDITOR.contains("Create overlay"));
1026 assert!(EDITOR.contains("Ambiguous analyses"));
1027 assert!(!EDITOR.contains("Lexicon overlay JSON"));
1028 }
1029
1030 fn request(path: &Path, lexicon: &Path, request: String) -> String {
1031 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1032 let address = listener.local_addr().unwrap();
1033 let overlay = path.to_owned();
1034 let lexicon = lexicon.to_owned();
1035 let hebrew = lexicon.clone();
1036 let server = std::thread::spawn(move || {
1037 let (stream, _) = listener.accept().unwrap();
1038 handle(stream, &overlay, &lexicon, &hebrew).unwrap();
1039 });
1040 let mut client = TcpStream::connect(address).unwrap();
1041 client.write_all(request.as_bytes()).unwrap();
1042 let mut response = String::new();
1043 client.read_to_string(&mut response).unwrap();
1044 server.join().unwrap();
1045 response
1046 }
1047
1048 #[test]
1049 #[ignore = "requires loopback sockets, which some build sandboxes disable"]
1050 fn api_reads_and_validates_saves() {
1051 let path = std::env::temp_dir().join(format!("haqor-admin-{}.json", std::process::id()));
1052 let original = json!({"lexicon_entries": [], "word_glosses": [], "primary_analyses": []});
1053 std::fs::write(&path, serde_json::to_string(&original).unwrap()).unwrap();
1054 let lexicon =
1055 std::env::temp_dir().join(format!("haqor-admin-lexicon-{}.db", std::process::id()));
1056 let db = Connection::open(&lexicon).unwrap();
1057 db.execute_batch(
1058 "CREATE TABLE bdb(bdb_id TEXT, word TEXT, root TEXT, gloss TEXT);
1059 CREATE TABLE english(strong INTEGER, word TEXT, gloss TEXT);
1060 INSERT INTO bdb VALUES ('a.b', 'אָב', 'אב', 'father');",
1061 )
1062 .unwrap();
1063 drop(db);
1064
1065 let response = request(
1066 &path,
1067 &lexicon,
1068 "GET /api/overlay HTTP/1.1\r\nHost: localhost\r\n\r\n".into(),
1069 );
1070 assert!(response.starts_with("HTTP/1.1 200 OK"));
1071 assert!(response.contains("word_glosses"));
1072
1073 let invalid = r#"{"lexicon_entries":[],"word_glosses":[{}]}"#;
1074 let response = request(
1075 &path,
1076 &lexicon,
1077 format!(
1078 "PUT /api/overlay HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\n\r\n{invalid}",
1079 invalid.len()
1080 ),
1081 );
1082 assert!(response.starts_with("HTTP/1.1 400 Bad Request"));
1083 assert_eq!(
1084 serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(&path).unwrap())
1085 .unwrap(),
1086 original
1087 );
1088 std::fs::remove_file(path).unwrap();
1089 std::fs::remove_file(lexicon).unwrap();
1090 }
1091
1092 #[test]
1093 fn lexicon_catalogue_reads_imported_glosses() {
1094 let path =
1095 std::env::temp_dir().join(format!("haqor-admin-catalogue-{}.db", std::process::id()));
1096 let db = Connection::open(&path).unwrap();
1097 db.execute_batch(
1098 "CREATE TABLE bdb(bdb_id TEXT, word TEXT, root TEXT, gloss TEXT);
1099 CREATE TABLE english(strong INTEGER, word TEXT, gloss TEXT);
1100 INSERT INTO bdb VALUES ('a.b', 'אָב', 'אב', 'father');
1101 INSERT INTO english VALUES (1, 'אָב', 'father');",
1102 )
1103 .unwrap();
1104 drop(db);
1105 let value = read_lexicon(&path).unwrap();
1106 assert_eq!(value["entries"].as_array().unwrap().len(), 2);
1107 assert_eq!(value["entries"][0]["surface"], "אָב");
1108 std::fs::remove_file(path).unwrap();
1109 }
1110
1111 #[test]
1112 fn ambiguity_catalogue_includes_noun_candidates() {
1113 let path =
1114 std::env::temp_dir().join(format!("haqor-admin-ambiguity-{}.db", std::process::id()));
1115 let db = Connection::open(&path).unwrap();
1116 db.execute_batch(
1117 "CREATE TABLE surface(surface_id INTEGER, text TEXT, occurrences INTEGER,
1118 n_candidates INTEGER, lexical_class TEXT, language TEXT);
1119 CREATE TABLE analyses(analysis_id INTEGER, surface_id INTEGER, root TEXT,
1120 binyan TEXT, form TEXT, pgn TEXT, prefix TEXT, vav_consecutive INTEGER,
1121 obj_suffix TEXT, attested INTEGER);
1122 CREATE TABLE noun_analyses(analysis_id INTEGER, surface_id INTEGER, stem TEXT,
1123 kind TEXT, label TEXT, prefix TEXT);
1124 INSERT INTO surface VALUES (1, 'אִישׁ', 10, 2, NULL, NULL);
1125 INSERT INTO analyses VALUES (1, 1, 'איש', 'Qal', 'Imperative', '2ms', '', 0, '', 1);
1126 INSERT INTO noun_analyses VALUES (2, 1, 'אִישׁ', 'Masculine', 'Irregular (man)', '');
1127 INSERT INTO noun_analyses VALUES (3, 1, 'אִישׁ', 'Masculine', 'Irregular (man)', '');",
1128 )
1129 .unwrap();
1130 drop(db);
1131 let value = read_ambiguous(&path).unwrap();
1132 let analyses = value["surfaces"][0]["analyses"].as_array().unwrap();
1133 assert!(analyses.iter().any(|a| a["analysis_type"] == "verb"));
1134 assert!(analyses.iter().any(|a| a["analysis_type"] == "noun"));
1135 assert_eq!(analyses.len(), 2, "equivalent noun rows are deduplicated");
1136 std::fs::remove_file(path).unwrap();
1137 }
1138
1139 #[test]
1140 fn pull_merges_mobile_glosses_without_losing_name_metadata() -> Result<()> {
1141 let base = std::env::temp_dir().join(format!("haqor-admin-pull-{}", std::process::id()));
1142 let overlay = base.with_extension("json");
1143 let progress = base.with_extension("db");
1144 let _ = std::fs::remove_file(&overlay);
1145 let _ = std::fs::remove_file(&progress);
1146 std::fs::write(
1147 &overlay,
1148 r#"{"lexicon_entries":[],"word_glosses":[{"surface":"דָּבָר","gloss":"word","is_name":true}],"primary_analyses":[]}"#,
1149 )?;
1150 let db = Connection::open(&progress)?;
1151 db.execute_batch(
1152 "CREATE TABLE gloss_overrides(surface TEXT PRIMARY KEY, gloss TEXT NOT NULL,
1153 note TEXT NOT NULL DEFAULT '', updated_epoch INTEGER NOT NULL);
1154 INSERT INTO gloss_overrides VALUES ('דָּבָר', 'matter', 'In this context.', 1);
1155 INSERT INTO gloss_overrides VALUES ('טוֹב', 'good', '', 2);
1156 INSERT INTO gloss_overrides VALUES ('שָׁבוּר', '', '', 3);
1157 CREATE TABLE lexicon_entry_overrides(
1158 surface TEXT PRIMARY KEY, root TEXT NOT NULL DEFAULT '',
1159 gloss TEXT NOT NULL, reader_gloss TEXT NOT NULL DEFAULT '',
1160 updated_epoch INTEGER NOT NULL);
1161 INSERT INTO lexicon_entry_overrides
1162 VALUES ('דָּבָר', 'דבר', 'speech, word', 'thing', 4);",
1163 )?;
1164 drop(db);
1165
1166 assert_eq!(
1167 pull_gloss_overrides(&progress, &overlay)?,
1168 PullGlossOverridesResult { total: 3, new: 2 }
1169 );
1170 let value: Value = serde_json::from_str(&std::fs::read_to_string(&overlay)?)?;
1171 let rows = value["word_glosses"].as_array().unwrap();
1172 assert_eq!(rows[0]["gloss"], "thing");
1173 assert_eq!(rows[0]["note"], "In this context.");
1174 assert_eq!(rows[0]["is_name"], true);
1175 assert_eq!(rows[1]["surface"], "טוֹב");
1176 assert!(rows[1].get("note").is_none());
1177 assert!(rows.iter().all(|row| row["surface"] != "שָׁבוּר"));
1178 let entry = &value["lexicon_entries"][0];
1179 assert_eq!(entry["surface"], "דָּבָר");
1180 assert_eq!(entry["root"], "דבר");
1181 assert_eq!(entry["gloss"], "speech, word");
1182 assert_eq!(
1183 pull_gloss_overrides(&progress, &overlay)?,
1184 PullGlossOverridesResult { total: 3, new: 0 }
1185 );
1186 std::fs::remove_file(overlay)?;
1187 std::fs::remove_file(progress)?;
1188 Ok(())
1189 }
1190
1191 #[test]
1192 fn clear_invalid_glosses_marks_only_blank_rows_deleted() -> Result<()> {
1193 let progress = std::env::temp_dir().join(format!(
1194 "haqor-admin-clear-invalid-glosses-{}.db",
1195 std::process::id()
1196 ));
1197 let _ = std::fs::remove_file(&progress);
1198 let db = Connection::open(&progress)?;
1199 db.execute_batch(
1200 "CREATE TABLE gloss_overrides(
1201 surface TEXT PRIMARY KEY, gloss TEXT NOT NULL, note TEXT NOT NULL DEFAULT '',
1202 updated_epoch INTEGER NOT NULL
1203 );
1204 INSERT INTO gloss_overrides VALUES ('טוֹב', 'good', '', 1);
1205 INSERT INTO gloss_overrides VALUES ('אֵל', '', '', 2);
1206 INSERT INTO gloss_overrides VALUES ('', 'blank surface', '', 3);",
1207 )?;
1208 drop(db);
1209
1210 assert_eq!(invalid_gloss_override_count(&progress)?, 2);
1211 assert_eq!(clear_invalid_gloss_overrides_file(&progress)?, 2);
1212 assert_eq!(invalid_gloss_override_count(&progress)?, 0);
1213 let db = Connection::open(&progress)?;
1214 assert_eq!(
1215 db.query_row(
1216 "SELECT deleted FROM gloss_overrides WHERE surface = 'טוֹב'",
1217 [],
1218 |row| row.get::<_, i64>(0),
1219 )?,
1220 0
1221 );
1222 assert_eq!(
1223 db.query_row(
1224 "SELECT COUNT(*) FROM gloss_overrides WHERE deleted = 1",
1225 [],
1226 |row| { row.get::<_, i64>(0) }
1227 )?,
1228 2
1229 );
1230 std::fs::remove_file(progress)?;
1231 Ok(())
1232 }
1233
1234 #[test]
1235 fn pull_exports_issue_reports_with_structured_context() -> Result<()> {
1236 let base =
1237 std::env::temp_dir().join(format!("haqor-admin-pull-issues-{}", std::process::id()));
1238 let progress = base.with_extension("db");
1239 let output = base.with_extension("json");
1240 let _ = std::fs::remove_file(&progress);
1241 let _ = std::fs::remove_file(&output);
1242 let db = Connection::open(&progress)?;
1243 db.execute_batch(
1244 "CREATE TABLE issue_reports(
1245 id TEXT PRIMARY KEY, report_type TEXT NOT NULL, note TEXT NOT NULL,
1246 context_json TEXT NOT NULL, created_epoch INTEGER NOT NULL,
1247 updated_epoch INTEGER NOT NULL);
1248 INSERT INTO issue_reports VALUES (
1249 'phone-1', 'bug', 'Wrong answer shown',
1250 '{\"source\":\"tutor_card\",\"card\":{\"kind\":\"review_word\"}}',
1251 100, 100);",
1252 )?;
1253 drop(db);
1254
1255 assert_eq!(pull_issue_reports(&progress, &output)?, 1);
1256 let value: Value = serde_json::from_str(&std::fs::read_to_string(&output)?)?;
1257 let report = &value["issueReports"][0];
1258 assert_eq!(report["id"], "phone-1");
1259 assert_eq!(report["type"], "bug");
1260 assert_eq!(report["context"]["source"], "tutor_card");
1261 assert_eq!(report["context"]["card"]["kind"], "review_word");
1262
1263 std::fs::remove_file(progress)?;
1264 std::fs::remove_file(output)?;
1265 Ok(())
1266 }
1267
1268 #[test]
1269 fn pull_issues_rejects_a_legacy_server_snapshot() -> Result<()> {
1270 let base = std::env::temp_dir().join(format!(
1271 "haqor-admin-pull-legacy-issues-{}",
1272 std::process::id()
1273 ));
1274 let progress = base.with_extension("db");
1275 let output = base.with_extension("json");
1276 let _ = std::fs::remove_file(&progress);
1277 let _ = std::fs::remove_file(&output);
1278 Connection::open(&progress)?
1279 .execute_batch("CREATE TABLE legacy_progress(value INTEGER NOT NULL);")?;
1280
1281 let error = pull_issue_reports(&progress, &output).unwrap_err();
1282 assert!(error.to_string().contains("sync-server is out of date"));
1283 assert!(!output.exists());
1284
1285 std::fs::remove_file(progress)?;
1286 Ok(())
1287 }
1288
1289 #[test]
1290 fn reads_flutter_sync_settings_without_exposing_the_token() -> Result<()> {
1291 let preferences = std::env::temp_dir().join(format!(
1292 "haqor-admin-preferences-{}-{}.json",
1293 std::process::id(),
1294 std::thread::current().name().unwrap_or("test")
1295 ));
1296 std::fs::write(
1297 &preferences,
1298 r#"{"flutter.progress_sync_server_url":" http://192.168.1.10:8788 ","flutter.progress_sync_token":" secret "}"#,
1299 )?;
1300 assert_eq!(
1301 read_app_sync_settings(&preferences)?,
1302 AppSyncSettings {
1303 server_url: "http://192.168.1.10:8788".to_string(),
1304 token: "secret".to_string(),
1305 }
1306 );
1307 std::fs::remove_file(preferences)?;
1308 Ok(())
1309 }
1310
1311 #[test]
1312 fn root_server_url_uses_the_progress_endpoint() -> Result<()> {
1313 let endpoint = parse_sync_endpoint("http://sync.example:8788/")?;
1314
1315 assert_eq!(endpoint.host, "sync.example");
1316 assert_eq!(endpoint.port, 8788);
1317 assert_eq!(endpoint.path, "/v1/progress");
1318 Ok(())
1319 }
1320
1321 #[test]
1322 fn empty_snapshot_fallback_preserves_server_progress() -> Result<()> {
1323 let base =
1324 std::env::temp_dir().join(format!("haqor-admin-empty-snapshot-{}", std::process::id()));
1325 let canonical = base.with_extension("canonical.db");
1326 let incoming = base.with_extension("incoming.db");
1327 let _ = std::fs::remove_file(&canonical);
1328 let _ = std::fs::remove_file(&incoming);
1329 let db = Connection::open_in_memory()?;
1330 db.execute(
1331 "ATTACH DATABASE ?1 AS progress",
1332 [canonical.to_string_lossy().as_ref()],
1333 )?;
1334 haqor_core::tutor::init_progress_schema(&db)?;
1335 db.execute(
1336 "INSERT INTO progress.gloss_overrides(surface, gloss, note, updated_epoch)
1337 VALUES ('דָּבָר', 'word', '', 1)",
1338 [],
1339 )?;
1340 drop(db);
1341
1342 std::fs::write(&incoming, empty_progress_snapshot()?)?;
1343 haqor_core::progress_sync::merge_progress_files(&canonical, &incoming)?;
1344 let canonical_db = Connection::open(&canonical)?;
1345 assert_eq!(
1346 canonical_db.query_row(
1347 "SELECT gloss FROM gloss_overrides WHERE surface = 'דָּבָר'",
1348 [],
1349 |row| row.get::<_, String>(0),
1350 )?,
1351 "word"
1352 );
1353 std::fs::remove_file(canonical)?;
1354 std::fs::remove_file(incoming)?;
1355 Ok(())
1356 }
1357
1358 #[test]
1359 fn default_settings_follow_xdg_data_home_and_migrate_the_template_id() -> Result<()> {
1360 let data_home = std::env::temp_dir().join(format!(
1361 "haqor-admin-xdg-{}-{}",
1362 std::process::id(),
1363 std::thread::current().name().unwrap_or("test")
1364 ));
1365 let legacy = shared_preferences_path(&data_home, LEGACY_APP_ID);
1366 std::fs::create_dir_all(legacy.parent().unwrap())?;
1367 std::fs::write(
1368 &legacy,
1369 r#"{"flutter.progress_sync_server_url":"http://sync:8788","flutter.progress_sync_token":"token"}"#,
1370 )?;
1371 assert_eq!(
1372 read_app_sync_settings_from_data_home(&data_home)?,
1373 AppSyncSettings {
1374 server_url: "http://sync:8788".to_string(),
1375 token: "token".to_string(),
1376 }
1377 );
1378 assert_eq!(
1379 shared_preferences_path(&data_home, APP_ID),
1380 data_home.join("org.haqor/shared_preferences.json")
1381 );
1382 std::fs::remove_dir_all(data_home)?;
1383 Ok(())
1384 }
1385}