Skip to main content

haqor_admin/
lib.rs

1//! Minimal local HTTP server for editing `data/lexicon_overrides.json`.
2
3use 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/// The server credentials the Flutter app keeps in its platform preferences.
22/// They are read only for a `pull` invocation and are never printed.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct AppSyncSettings {
25    pub server_url: String,
26    pub token: String,
27}
28
29/// Summary of mobile lexicon corrections merged into the local overlay.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct PullGlossOverridesResult {
32    /// All valid corrections currently stored in synced progress data.
33    pub total: usize,
34    /// Corrections whose target overlay row was added by this merge.
35    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
50/// Read the settings from Haqor's XDG data directory. During the application
51/// ID migration, fall back to Flutter's former template ID only when the new
52/// settings file does not yet exist, so existing LAN sync credentials survive
53/// the upgrade.
54pub 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
76/// Read the sync endpoint the Haqor app has already been configured to use.
77/// Flutter's Linux shared-preference backend prefixes its keys with `flutter.`.
78pub 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
112/// Pull mobile lexicon corrections from the canonical sync database into the
113/// hand-maintained overlay. Tutor corrections update `word_glosses`, while
114/// word-info corrections update `lexicon_entries` root/header gloss rows and,
115/// when supplied, their matching `word_glosses` interlinear rows.
116pub 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
226/// Fetch the canonical progress snapshot from an authenticated LAN sync server
227/// and merge its mobile lexicon corrections into the local overlay JSON.
228pub 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
238/// Mark malformed blank tutor-gloss rows deleted in the canonical sync database.
239///
240/// The returned server snapshot is checked before reporting success, so an older
241/// server that fails to retain tombstones cannot silently leave the bad rows live.
242pub 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
341/// Export the synchronised mobile bug/idea log as deterministic, pretty JSON.
342pub 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
384/// Fetch the canonical progress snapshot from the LAN server and export its
385/// issue/idea log to a local JSON file.
386pub 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
396/// Run an interactive terminal review of the issue log in a local progress
397/// snapshot. Pressing `s` in the review marks selected reports resolved in
398/// that snapshot; tombstones prevent stale devices from restoring them.
399pub 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                &note,
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
445/// Review the server's live issue log, then upload the selected resolution
446/// tombstones through the normal authenticated progress-sync route.
447pub 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                    &note,
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
684/// An empty, fully initialized snapshot safely exercises a pre-download sync
685/// server's POST route: merging it cannot change any learner data, and the
686/// server returns its canonical snapshot in response.
687fn 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}