1use super::client::MonolingualClient;
8use super::db::Db;
9use super::errors::MonolingualError;
10use super::metadata::{DictionariesResponse, DictionaryEntry, download_url, download_url_no_etym};
11use crate::db::Database;
12use std::collections::HashSet;
13use std::fs;
14use std::io::{self};
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex, MutexGuard};
17use zip::ZipArchive;
18
19const READER_DICT_SUBDIR: &str = "reader-dict";
21
22#[derive(Clone, Debug)]
34pub struct MonolingualDictionaryService {
35 client: MonolingualClient,
36 db: Db,
37 dict_dir: PathBuf,
38 pending_installs: Arc<Mutex<HashSet<String>>>,
39}
40
41impl MonolingualDictionaryService {
42 #[cfg_attr(feature = "tracing", tracing::instrument(skip(database), fields(dict_dir = %dict_dir.display())))]
54 pub fn new(database: &Database, dict_dir: &Path) -> Result<Self, MonolingualError> {
55 let client = MonolingualClient::new()?;
56 let db = Db::new(database);
57 Ok(Self {
58 client,
59 db,
60 dict_dir: dict_dir.to_path_buf(),
61 pending_installs: Arc::new(Mutex::new(HashSet::new())),
62 })
63 }
64
65 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
74 pub fn get_available_dictionaries(
75 &self,
76 ) -> Result<Vec<(String, DictionaryEntry)>, MonolingualError> {
77 let metadata = self.load_metadata()?;
78
79 let monolingual = metadata
80 .into_iter()
81 .filter_map(|(lang, mut targets)| targets.remove(&lang).map(|entry| (lang, entry)))
82 .collect();
83
84 Ok(monolingual)
85 }
86
87 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(lang = %lang)))]
96 pub fn get_entry_for_lang(
97 &self,
98 lang: &str,
99 ) -> Result<Option<DictionaryEntry>, MonolingualError> {
100 Ok(self.db.get_entry(lang)?)
101 }
102
103 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
113 pub fn get_installed_dictionaries(&self) -> Result<Vec<String>, MonolingualError> {
114 let root = self.reader_dict_dir();
115
116 if !root.exists() {
117 return Ok(Vec::new());
118 }
119
120 let mut installed = Vec::new();
121
122 for entry in fs::read_dir(&root)? {
123 let entry = entry?;
124 let path = entry.path();
125
126 if !path.is_dir() {
127 continue;
128 }
129
130 if has_dict_pair(&path) {
131 if let Some(lang) = path.file_name().and_then(|n| n.to_str()) {
132 installed.push(lang.to_string());
133 }
134 }
135 }
136
137 Ok(installed)
138 }
139
140 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), ret(level=tracing::Level::TRACE)))]
145 pub fn is_installing(&self, lang: &str) -> bool {
146 #[cfg(feature = "tracing")]
147 let _span = tracing::info_span!("lock").entered();
148 self.pending_installs().contains(lang)
149 }
150
151 fn pending_installs(&self) -> MutexGuard<'_, HashSet<String>> {
152 self.pending_installs.lock().unwrap_or_else(|poisoned| {
153 tracing::warn!("Pending installs lock poisoned; continuing anyway");
154 poisoned.into_inner()
155 })
156 }
157
158 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), ret(level=tracing::Level::TRACE)))]
162 pub(crate) fn try_begin_install(&self, lang: &str) -> bool {
163 #[cfg(feature = "tracing")]
164 let _span = tracing::info_span!("lock").entered();
165
166 let mut pending = self.pending_installs();
167
168 if pending.contains(lang) {
169 return false;
170 }
171
172 pending.insert(lang.to_string());
173 true
174 }
175
176 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
177 fn finish_install(&self, lang: &str) {
178 #[cfg(feature = "tracing")]
179 let _span = tracing::info_span!("lock").entered();
180 self.pending_installs().remove(lang);
181 }
182
183 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, entry, progress_callback), fields(lang = %lang, include_etymologies)))]
211 pub fn install_dictionary<F>(
212 &self,
213 lang: &str,
214 entry: &DictionaryEntry,
215 include_etymologies: bool,
216 progress_callback: &mut F,
217 ) -> Result<(), MonolingualError>
218 where
219 F: FnMut(u64, u64),
220 {
221 if !self.try_begin_install(lang) {
222 return Err(MonolingualError::InstallationInProgress(lang.to_string()));
223 }
224
225 self.install_reserved_dictionary(lang, entry, include_etymologies, progress_callback)
226 }
227
228 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, entry, progress_callback), fields(lang = %lang, include_etymologies)))]
233 pub(crate) fn install_reserved_dictionary<F>(
234 &self,
235 lang: &str,
236 entry: &DictionaryEntry,
237 include_etymologies: bool,
238 progress_callback: &mut F,
239 ) -> Result<(), MonolingualError>
240 where
241 F: FnMut(u64, u64),
242 {
243 let result = self.do_install(lang, entry, include_etymologies, progress_callback);
244 self.finish_install(lang);
245
246 result
247 }
248
249 #[cfg_attr(
250 feature = "tracing",
251 tracing::instrument(skip(self, entry, progress_callback))
252 )]
253 fn do_install<F>(
254 &self,
255 lang: &str,
256 entry: &DictionaryEntry,
257 include_etymologies: bool,
258 progress_callback: &mut F,
259 ) -> Result<(), MonolingualError>
260 where
261 F: FnMut(u64, u64),
262 {
263 let url = if include_etymologies {
264 download_url(lang)
265 } else {
266 download_url_no_etym(lang)
267 };
268
269 tracing::info!(lang, url = %url, "Downloading dictionary");
270
271 let dest = self.lang_dir(lang);
272 fs::create_dir_all(&dest)?;
273
274 let temp_path = dest.join(".download.tmp");
275
276 self.client.download(&url, &temp_path, progress_callback)?;
277
278 tracing::debug!(lang, dest = %dest.display(), "Extracting dictionary archive");
279
280 let file = fs::File::open(&temp_path)?;
281 extract_zip_renamed(file, &dest, lang)?;
282
283 fs::remove_file(&temp_path)?;
284
285 if let Err(e) = self.db.record_install(lang, entry.updated.into()) {
286 tracing::warn!(lang, error = %e, "Failed to record dictionary install");
287 }
288
289 tracing::info!(lang, dest = %dest.display(), "Dictionary installed");
290
291 Ok(())
292 }
293
294 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
299 pub fn remove_installed(&self, lang: &str) {
300 if let Err(e) = self.db.remove_installed(lang) {
301 tracing::warn!(lang, error = %e, "Failed to remove installed dictionary record");
302 }
303 }
304
305 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
310 pub fn is_update_available(&self, lang: &str) -> bool {
311 self.db.is_update_available(lang).unwrap_or(false)
312 }
313
314 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
315 fn load_metadata(&self) -> Result<DictionariesResponse, MonolingualError> {
316 if let Some(cached_at) = self.db.get_most_recent_cached_at()? {
317 match self.client.is_metadata_modified_since(cached_at) {
318 Ok(false) => {
319 tracing::debug!("Cache is fresh (304), using cached metadata");
320 if let Some(cached) = self.get_cached_metadata()? {
321 return Ok(cached);
322 }
323 }
324 Ok(true) => {
325 tracing::debug!("API has newer data (200), refreshing cache");
326 }
327 Err(e) => {
328 tracing::warn!(error = %e, "HEAD check failed, falling back to cache");
329 if let Some(cached) = self.get_cached_metadata()? {
330 return Ok(cached);
331 }
332 }
333 }
334 }
335
336 self.fetch_and_cache_metadata().or_else(|_| {
337 self.get_cached_metadata()?
338 .ok_or_else(|| MonolingualError::NotFound("metadata unavailable".to_string()))
339 })
340 }
341
342 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
343 fn fetch_and_cache_metadata(&self) -> Result<DictionariesResponse, MonolingualError> {
344 let metadata = self.client.fetch_metadata()?;
345
346 for (source_lang, targets) in &metadata {
347 if let Some(entry) = targets.get(source_lang.as_str()) {
348 self.db.upsert_entry(source_lang, entry)?;
349 }
350 }
351
352 tracing::debug!("Cached monolingual metadata to database");
353 Ok(metadata)
354 }
355
356 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
357 fn get_cached_metadata(&self) -> Result<Option<DictionariesResponse>, MonolingualError> {
358 let entries = self.db.get_all_entries()?;
359
360 if entries.is_empty() {
361 tracing::debug!("No cached metadata found in database");
362 return Ok(None);
363 }
364
365 let mut response = DictionariesResponse::new();
366 for (lang, entry) in entries {
367 response
368 .entry(lang.clone())
369 .or_default()
370 .insert(lang, entry);
371 }
372
373 tracing::debug!("Loaded cached metadata from database");
374 Ok(Some(response))
375 }
376
377 fn reader_dict_dir(&self) -> PathBuf {
378 self.dict_dir.join(READER_DICT_SUBDIR)
379 }
380
381 fn lang_dir(&self, lang: &str) -> PathBuf {
382 self.reader_dict_dir().join(lang)
383 }
384}
385
386fn has_dict_pair(dir: &Path) -> bool {
389 let Ok(entries) = fs::read_dir(dir) else {
390 return false;
391 };
392
393 for entry in entries.flatten() {
394 let path = entry.path();
395 let name = match path.file_name().and_then(|n| n.to_str()) {
396 Some(n) => n.to_string(),
397 None => continue,
398 };
399
400 if !name.ends_with(".index") {
401 continue;
402 }
403
404 let stem = &name[..name.len() - ".index".len()];
405 let dict = dir.join(format!("{stem}.dict"));
406 let dict_dz = dir.join(format!("{stem}.dict.dz"));
407
408 if dict.exists() || dict_dz.exists() {
409 return true;
410 }
411 }
412
413 false
414}
415
416#[cfg_attr(feature = "tracing", tracing::instrument(skip(reader)))]
423fn extract_zip_renamed<R: std::io::Read + std::io::Seek>(
424 reader: R,
425 dest: &Path,
426 lang: &str,
427) -> Result<(), MonolingualError> {
428 let mut archive = ZipArchive::new(reader)
429 .map_err(|e| MonolingualError::Extraction(format!("failed to open zip archive: {e}")))?;
430
431 for i in 0..archive.len() {
432 let mut file = archive.by_index(i).map_err(|e| {
433 MonolingualError::Extraction(format!("failed to read zip entry {i}: {e}"))
434 })?;
435
436 if file.is_dir() {
437 continue;
438 }
439
440 let original_name = match file.enclosed_name() {
441 Some(p) => p
442 .file_name()
443 .and_then(|n| n.to_str())
444 .unwrap_or("")
445 .to_string(),
446 None => {
447 tracing::warn!(index = i, "Skipping zip entry with unsafe path");
448 continue;
449 }
450 };
451
452 let target_name = dict_file_target_name(&original_name, lang);
453 let Some(target_name) = target_name else {
454 tracing::debug!(
455 original_name,
456 "Skipping zip entry with unrecognised extension"
457 );
458 continue;
459 };
460
461 let out_path = dest.join(&target_name);
462 let mut out_file = fs::File::create(&out_path)?;
463 io::copy(&mut file, &mut out_file)?;
464 tracing::debug!(path = %out_path.display(), "Extracted file");
465 }
466
467 Ok(())
468}
469
470fn dict_file_target_name(original: &str, lang: &str) -> Option<String> {
479 for ext in &[".dict.dz", ".dict", ".index"] {
480 if original.ends_with(ext) {
481 return Some(format!("Reader-Dict-{lang}{ext}"));
482 }
483 }
484 None
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use crate::db::Database;
491 use crate::dictionary::monolingual::metadata::DictionaryEntry;
492 use chrono::NaiveDate;
493 use std::io::Cursor;
494 use std::io::Write;
495 use tempfile::TempDir;
496
497 fn create_test_service() -> (MonolingualDictionaryService, TempDir, Database) {
498 crate::crypto::init_crypto_provider();
499 let dir = TempDir::new().expect("failed to create temp dir");
500 let mut database = Database::new(":memory:").expect("failed to create in-memory database");
501 database.init(0).expect("failed to run migrations");
502 let service = MonolingualDictionaryService::new(&database, dir.path())
503 .expect("failed to create service");
504 (service, dir, database)
505 }
506
507 fn make_entry(year: i32, month: u32, day: u32) -> DictionaryEntry {
508 DictionaryEntry {
509 formats: "df,dic,dictorg,kobo,mobi,stardict".to_string(),
510 updated: NaiveDate::from_ymd_opt(year, month, day).unwrap(),
511 words: 1_381_375,
512 }
513 }
514
515 #[test]
516 fn test_get_installed_empty_when_no_dir() {
517 let (service, _dir, _db) = create_test_service();
518 let installed = service.get_installed_dictionaries().unwrap();
519 assert!(installed.is_empty());
520 }
521
522 #[test]
523 fn test_get_installed_empty_when_dir_exists_but_empty() {
524 let (service, dir, _db) = create_test_service();
525 fs::create_dir_all(dir.path().join(READER_DICT_SUBDIR)).unwrap();
526 let installed = service.get_installed_dictionaries().unwrap();
527 assert!(installed.is_empty());
528 }
529
530 #[test]
531 fn test_get_installed_detects_dict_pair() {
532 let (service, dir, _db) = create_test_service();
533 let lang_dir = dir.path().join(READER_DICT_SUBDIR).join("en");
534 fs::create_dir_all(&lang_dir).unwrap();
535 fs::File::create(lang_dir.join("dict.index")).unwrap();
536 fs::File::create(lang_dir.join("dict.dict")).unwrap();
537
538 let installed = service.get_installed_dictionaries().unwrap();
539 assert_eq!(installed, vec!["en".to_string()]);
540 }
541
542 #[test]
543 fn test_get_installed_detects_dict_dz_pair() {
544 let (service, dir, _db) = create_test_service();
545 let lang_dir = dir.path().join(READER_DICT_SUBDIR).join("fr");
546 fs::create_dir_all(&lang_dir).unwrap();
547 fs::File::create(lang_dir.join("dict.index")).unwrap();
548 fs::File::create(lang_dir.join("dict.dict.dz")).unwrap();
549
550 let installed = service.get_installed_dictionaries().unwrap();
551 assert_eq!(installed, vec!["fr".to_string()]);
552 }
553
554 #[test]
555 fn test_get_installed_ignores_index_without_dict() {
556 let (service, dir, _db) = create_test_service();
557 let lang_dir = dir.path().join(READER_DICT_SUBDIR).join("de");
558 fs::create_dir_all(&lang_dir).unwrap();
559 fs::File::create(lang_dir.join("dict.index")).unwrap();
560
561 let installed = service.get_installed_dictionaries().unwrap();
562 assert!(installed.is_empty());
563 }
564
565 #[test]
566 fn test_install_dictionary_extracts_zip_renamed() {
567 let (_service, dir, _db) = create_test_service();
568
569 let zip_bytes = make_test_zip(&[
570 ("dictorg-en-en.index", b"index content"),
571 ("dictorg-en-en.dict", b"dict content"),
572 ]);
573
574 let dest = dir.path().join(READER_DICT_SUBDIR).join("en");
575 fs::create_dir_all(&dest).unwrap();
576 extract_zip_renamed(Cursor::new(&zip_bytes), &dest, "en").unwrap();
577
578 assert!(dest.join("Reader-Dict-en.index").exists());
579 assert!(dest.join("Reader-Dict-en.dict").exists());
580 }
581
582 fn make_test_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
583 let mut buf = Vec::new();
584 {
585 let cursor = Cursor::new(&mut buf);
586 let mut zip = zip::ZipWriter::new(cursor);
587 let options = zip::write::SimpleFileOptions::default();
588 for (name, content) in entries {
589 zip.start_file(*name, options).unwrap();
590 zip.write_all(content).unwrap();
591 }
592 zip.finish().unwrap();
593 }
594 buf
595 }
596
597 #[test]
598 fn test_is_installing_false_initially() {
599 let (service, _dir, _db) = create_test_service();
600 assert!(!service.is_installing("en"));
601 }
602
603 #[test]
604 fn test_is_installing_true_while_pending() {
605 let (service, _dir, _db) = create_test_service();
606 service
607 .pending_installs
608 .lock()
609 .unwrap()
610 .insert("fr".to_string());
611 assert!(service.is_installing("fr"));
612 assert!(!service.is_installing("en"));
613 }
614
615 #[test]
616 fn test_try_begin_install_marks_pending_and_blocks_duplicate() {
617 let (service, _dir, _db) = create_test_service();
618
619 assert!(service.try_begin_install("en"));
620 assert!(service.is_installing("en"));
621 assert!(!service.try_begin_install("en"));
622 }
623
624 #[test]
625 fn test_pending_installs_recovers_from_poisoned_lock() {
626 let (service, _dir, _db) = create_test_service();
627 let service_clone = service.clone();
628
629 let result = std::thread::spawn(move || {
630 let _guard = service_clone.pending_installs.lock().unwrap();
631 panic!("poison pending installs lock");
632 })
633 .join();
634
635 assert!(result.is_err());
636 assert!(service.try_begin_install("en"));
637 assert!(service.is_installing("en"));
638 service.finish_install("en");
639 assert!(!service.is_installing("en"));
640 }
641
642 #[test]
643 fn test_is_installing_false_after_removal() {
644 let (service, _dir, _db) = create_test_service();
645 service
646 .pending_installs
647 .lock()
648 .unwrap()
649 .insert("en".to_string());
650 service.pending_installs.lock().unwrap().remove("en");
651 assert!(!service.is_installing("en"));
652 }
653
654 #[test]
655 fn test_concurrent_install_same_lang_returns_error() {
656 let (service, _dir, _db) = create_test_service();
657 service
658 .pending_installs
659 .lock()
660 .unwrap()
661 .insert("de".to_string());
662
663 let entry = make_entry(2026, 4, 1);
664 let err = service
665 .install_dictionary("de", &entry, false, &mut |_, _| {})
666 .expect_err("expected InstallationInProgress error");
667
668 assert!(
669 matches!(err, MonolingualError::InstallationInProgress(_)),
670 "unexpected error variant: {err}"
671 );
672 }
673
674 #[test]
675 fn test_pending_cleared_after_failed_install() {
676 let (service, _dir, _db) = create_test_service();
677
678 let entry = make_entry(2026, 4, 1);
679 let _ = service.install_dictionary("zz", &entry, false, &mut |_, _| {});
680 assert!(!service.is_installing("zz"));
681 }
682
683 #[test]
684 fn test_reserved_install_clears_after_failed_install() {
685 let (service, _dir, _db) = create_test_service();
686 let entry = make_entry(2026, 4, 1);
687
688 assert!(service.try_begin_install("zz"));
689
690 let _ = service.install_reserved_dictionary("zz", &entry, false, &mut |_, _| {});
691
692 assert!(!service.is_installing("zz"));
693 }
694
695 #[test]
696 fn test_is_installing_shared_across_clones() {
697 let (service, _dir, _db) = create_test_service();
698 let clone = service.clone();
699
700 service
701 .pending_installs
702 .lock()
703 .unwrap()
704 .insert("ja".to_string());
705
706 assert!(clone.is_installing("ja"));
707 }
708
709 #[test]
710 fn test_get_entry_for_lang_returns_none_when_not_cached() {
711 let (service, _dir, _db) = create_test_service();
712 let result = service.get_entry_for_lang("en").unwrap();
713 assert!(result.is_none());
714 }
715
716 #[test]
717 fn test_get_entry_for_lang_returns_entry_after_cache() {
718 let (service, _dir, _db) = create_test_service();
719
720 let entry = make_entry(2026, 4, 1);
721 service.db.upsert_entry("en", &entry).unwrap();
722
723 let result = service.get_entry_for_lang("en").unwrap();
724 assert!(result.is_some());
725 let fetched = result.unwrap();
726 assert_eq!(fetched.words, 1_381_375);
727 assert_eq!(
728 fetched.updated,
729 NaiveDate::from_ymd_opt(2026, 4, 1).unwrap()
730 );
731 }
732
733 #[test]
738 #[ignore = "requires network access to www.reader-dict.com"]
739 fn test_install_dictionary_live() {
740 let (service, dir, _db) = create_test_service();
741
742 let entry = service
743 .get_available_dictionaries()
744 .unwrap()
745 .into_iter()
746 .find(|(l, _)| l == "en")
747 .map(|(_, e)| e)
748 .expect("English dictionary should be available");
749
750 service
751 .install_dictionary("en", &entry, false, &mut |_, _| {})
752 .expect("install_dictionary failed");
753
754 let lang_dir = dir.path().join(READER_DICT_SUBDIR).join("en");
755 assert!(
756 lang_dir.exists(),
757 "language directory should exist after install"
758 );
759 assert!(
760 has_dict_pair(&lang_dir),
761 "expected .index + .dict/.dict.dz pair in {lang_dir:?}"
762 );
763
764 let installed = service
765 .get_installed_dictionaries()
766 .expect("get_installed_dictionaries failed");
767 assert!(
768 installed.contains(&"en".to_string()),
769 "expected 'en' in installed list, got {installed:?}"
770 );
771 }
772}