1use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::Path;
11use std::path::PathBuf;
12
13#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
15pub struct FolderRules {
16 #[serde(default)]
18 pub ignore_hidden: bool,
19 #[serde(default)]
21 pub max_file_size_mb: Option<u64>,
22 #[serde(default)]
24 pub allowed_extensions: Vec<String>,
25 #[serde(default)]
27 pub sync_method: FolderSyncMethod,
28 #[serde(default)]
30 pub startup_catchup_mode: Option<StartupCatchupMode>,
31 #[serde(default)]
33 pub delete_folder_to_album: bool,
34 #[serde(default)]
36 pub delete_album_to_folder: bool,
37 #[serde(default)]
40 pub include_xmp_sidecar: Option<bool>,
41}
42
43impl FolderRules {
44 pub fn normalized_extensions(&self) -> Vec<String> {
46 self.allowed_extensions
47 .iter()
48 .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
49 .filter(|ext| !ext.is_empty())
50 .collect()
51 }
52
53 pub fn matches(&self, path: &Path) -> bool {
55 if self.ignore_hidden
56 && path.components().any(|component| {
57 component
58 .as_os_str()
59 .to_str()
60 .map(|part| part.starts_with('.') && part.len() > 1)
61 .unwrap_or(false)
62 })
63 {
64 return false;
65 }
66
67 if let Some(limit_mb) = self.max_file_size_mb
68 && let Ok(metadata) = std::fs::metadata(path)
69 && metadata.len() > limit_mb.saturating_mul(1024 * 1024)
70 {
71 return false;
72 }
73
74 let normalized = self.normalized_extensions();
75 if !normalized.is_empty() {
76 let ext = path
77 .extension()
78 .and_then(std::ffi::OsStr::to_str)
79 .map(str::to_ascii_lowercase);
80 if ext
81 .as_deref()
82 .is_none_or(|ext| !normalized.iter().any(|allowed| allowed == ext))
83 {
84 return false;
85 }
86 }
87
88 true
89 }
90
91 pub fn xmp_sidecar_enabled(&self, global_default: bool) -> bool {
96 self.include_xmp_sidecar.unwrap_or(global_default)
97 }
98}
99
100#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
101pub enum FolderSyncMethod {
102 Full,
104 #[default]
106 UploadOnly,
107 DownloadOnly,
109}
110
111#[derive(Serialize, Deserialize, Debug, Clone)]
116#[serde(untagged)]
117pub enum WatchPathEntry {
118 Simple(String),
120 WithConfig {
122 path: String,
124 #[serde(default)]
126 album_id: Option<String>,
127 #[serde(default)]
129 album_name: Option<String>,
130 #[serde(default)]
132 rules: FolderRules,
133 },
134}
135
136impl WatchPathEntry {
137 pub fn path(&self) -> &str {
139 match self {
140 WatchPathEntry::Simple(p) => p,
141 WatchPathEntry::WithConfig { path, .. } => path,
142 }
143 }
144 pub fn album_name(&self) -> Option<&str> {
146 match self {
147 WatchPathEntry::Simple(_) => None,
148 WatchPathEntry::WithConfig { album_name, .. } => album_name.as_deref(),
149 }
150 }
151
152 pub fn rules(&self) -> FolderRules {
154 match self {
155 WatchPathEntry::Simple(_) => FolderRules::default(),
156 WatchPathEntry::WithConfig { rules, .. } => rules.clone(),
157 }
158 }
159
160 pub fn sync_method(&self) -> FolderSyncMethod {
162 self.rules().sync_method
163 }
164
165 pub fn startup_catchup_mode(&self, fallback: &StartupCatchupMode) -> StartupCatchupMode {
167 self.rules()
168 .startup_catchup_mode
169 .unwrap_or_else(|| fallback.clone())
170 }
171}
172
173pub fn best_matching_watch_entry<'a>(
178 path: &Path,
179 entries: &'a [WatchPathEntry],
180) -> Option<&'a WatchPathEntry> {
181 entries
182 .iter()
183 .filter(|entry| path.starts_with(Path::new(entry.path())))
184 .max_by_key(|entry| entry.path().len())
185}
186
187pub fn watch_entry_for_album<'a>(
189 album_name: &str,
190 entries: &'a [WatchPathEntry],
191) -> Option<&'a WatchPathEntry> {
192 entries
193 .iter()
194 .find(|entry| entry.album_name() == Some(album_name))
195}
196
197#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
199pub enum StartupCatchupMode {
200 #[default]
202 Full,
203 RecentOnly,
205 NewFilesOnly,
207}
208
209#[derive(Serialize, Deserialize, Debug, Clone)]
211pub struct ConfigData {
212 #[serde(default)]
214 pub internal_url: String,
215 #[serde(default)]
217 pub external_url: String,
218 #[serde(default = "default_true")]
220 pub internal_url_enabled: bool,
221 #[serde(default = "default_true")]
223 pub external_url_enabled: bool,
224 #[serde(default)]
226 pub watch_paths: Vec<WatchPathEntry>,
227 #[serde(default)]
229 pub run_on_startup: bool,
230 #[serde(default)]
232 pub pause_on_metered_network: bool,
233 #[serde(default)]
235 pub pause_on_battery_power: bool,
236 #[serde(default)]
238 pub background_sync_enabled: bool,
239 #[serde(default = "default_true")]
241 pub notifications_enabled: bool,
242 #[serde(default)]
244 pub startup_catchup_mode: StartupCatchupMode,
245 #[serde(default = "default_upload_concurrency")]
247 pub upload_concurrency: u8,
248 #[serde(default)]
250 pub quiet_hours_start: Option<u8>,
251 #[serde(default)]
253 pub quiet_hours_end: Option<u8>,
254 #[serde(default)]
256 pub library_view_enabled: bool,
257 #[serde(default)]
259 pub download_target_path: Option<String>,
260 #[serde(default)]
262 pub library_preview_full_resolution: bool,
263 #[serde(default = "default_grid_quality")]
265 pub library_grid_quality: String,
266 #[serde(default = "default_cache_disk_cap_mb")]
270 pub cache_disk_cap_mb: u32,
271 #[serde(default)]
274 pub raw_decode_cache_enabled: bool,
275 #[serde(default)]
279 pub raw_full_decode: bool,
280 #[serde(default = "default_true")]
282 pub show_unnamed_faces: bool,
283 #[serde(default)]
285 pub show_hidden_faces: bool,
286 #[serde(default = "default_true")]
289 pub upload_xmp_sidecars: bool,
290 #[serde(default)]
292 pub grid_border_width: f32,
293 #[serde(default = "default_grid_border_color")]
295 pub grid_border_color: String,
296}
297
298impl Default for ConfigData {
299 fn default() -> Self {
300 Self {
301 internal_url: String::new(),
302 external_url: String::new(),
303 internal_url_enabled: true,
304 external_url_enabled: true,
305 watch_paths: Vec::new(),
306 run_on_startup: false,
307 pause_on_metered_network: false,
308 pause_on_battery_power: false,
309 background_sync_enabled: false,
310 notifications_enabled: true,
311 startup_catchup_mode: StartupCatchupMode::default(),
312 upload_concurrency: default_upload_concurrency(),
313 quiet_hours_start: None,
314 quiet_hours_end: None,
315 library_view_enabled: false,
316 download_target_path: None,
317 library_preview_full_resolution: false,
318 library_grid_quality: default_grid_quality(),
319 cache_disk_cap_mb: default_cache_disk_cap_mb(),
320 raw_decode_cache_enabled: false,
321 raw_full_decode: false,
322 show_unnamed_faces: true,
323 show_hidden_faces: false,
324 upload_xmp_sidecars: true,
325 grid_border_width: 0.0,
326 grid_border_color: default_grid_border_color(),
327 }
328 }
329}
330
331fn default_true() -> bool {
333 true
334}
335
336fn default_grid_quality() -> String {
337 "thumbnail".to_string()
338}
339
340fn default_upload_concurrency() -> u8 {
342 3
343}
344
345fn default_cache_disk_cap_mb() -> u32 {
347 2000
348}
349
350fn default_grid_border_color() -> String {
351 "#ffffff".to_string()
352}
353
354#[derive(Clone)]
356pub struct Config {
357 pub data: ConfigData,
359 pub config_file: PathBuf,
361}
362
363impl Config {
364 pub fn new() -> Self {
366 let config_dir = crate::profile::config_dir()
367 .unwrap_or_else(|| PathBuf::from("~/.config").join(crate::profile::dir_segment()));
368
369 let config_file = config_dir.join("config.json");
370
371 let mut config = Config {
372 data: ConfigData::default(),
373 config_file,
374 };
375
376 config.load();
377 config
378 }
379
380 pub fn load(&mut self) -> bool {
382 if self.config_file.exists() {
383 if let Ok(content) = fs::read_to_string(&self.config_file) {
384 if let Ok(data) = serde_json::from_str(&content) {
385 self.data = data;
386 log::info!("Config loaded from: {}", self.config_file.display());
387 return true;
388 }
389 log::warn!("Config parse failed: {}", self.config_file.display());
390 }
391 } else {
392 log::info!(
393 "No config found, creating default at: {}",
394 self.config_file.display()
395 );
396 self.save();
397 }
398 false
399 }
400
401 pub fn save(&self) -> bool {
403 if let Ok(content) = serde_json::to_string_pretty(&self.data) {
404 match crate::util::atomic_write(&self.config_file, content.as_bytes()) {
405 Ok(()) => {
406 log::info!("Config saved to: {}", self.config_file.display());
407 true
408 }
409 Err(err) => {
410 log::error!(
411 "Failed to write config {}: {}",
412 self.config_file.display(),
413 err
414 );
415 false
416 }
417 }
418 } else {
419 false
420 }
421 }
422
423 pub fn get_api_key(&self) -> Option<String> {
432 let result = tokio::task::block_in_place(|| {
433 tokio::runtime::Handle::current().block_on(async {
434 let keyring = keyring_with_dbus_fallback().await?;
435 let account = crate::profile::keyring_account();
436 let attributes: Vec<(&str, &str)> =
437 vec![("service", "mimick"), ("account", account.as_str())];
438 let items = keyring.search_items(&attributes).await.map_err(Box::new)?;
439 if let Some(item) = items.first() {
440 let secret = item.secret().await.map_err(Box::new)?;
441 let key = String::from_utf8_lossy(&secret).trim().to_string();
442 if !key.is_empty() {
443 return Ok::<Option<String>, Box<oo7::Error>>(Some(key));
444 }
445 }
446 Ok(None)
447 })
448 });
449
450 match result {
451 Ok(key) => {
452 if key.is_some() {
453 log::debug!("API key retrieved via oo7 keyring.");
454 } else {
455 log::warn!(
456 "No API key found in keyring. \
457 User must configure it in Settings."
458 );
459 }
460 key
461 }
462 Err(e) => {
463 log::warn!(
464 "Keyring lookup failed ({}): {:?}",
465 keyring_error_hint(&e),
466 e
467 );
468 None
469 }
470 }
471 }
472
473 pub fn set_api_key(&self, key: &str) -> Result<(), String> {
479 let secret = key.to_string();
480 let result = tokio::task::block_in_place(|| {
481 tokio::runtime::Handle::current().block_on(async {
482 let keyring = keyring_with_dbus_fallback().await?;
483 let account = crate::profile::keyring_account();
484 let attributes: Vec<(&str, &str)> =
485 vec![("service", "mimick"), ("account", account.as_str())];
486 let label = match crate::profile::name() {
487 Some(profile) => format!("Mimick API Key ({})", profile),
488 None => "Mimick API Key".to_string(),
489 };
490 keyring
491 .create_item(&label, &attributes, secret.as_bytes(), true)
492 .await
493 .map_err(Box::new)?;
494 Ok::<(), Box<oo7::Error>>(())
495 })
496 });
497
498 match result {
499 Ok(()) => {
500 log::info!("API key saved via oo7 keyring.");
501 Ok(())
502 }
503 Err(e) => {
504 log::error!("Failed to save API key via oo7 keyring: {:?}", e);
505 Err(format!(
506 "{}\n\nTechnical detail: {}",
507 keyring_error_message(&e),
508 e
509 ))
510 }
511 }
512 }
513
514 pub fn watch_path_strings(&self) -> Vec<String> {
516 self.data
517 .watch_paths
518 .iter()
519 .map(|e| e.path().to_string())
520 .collect()
521 }
522}
523
524async fn keyring_with_dbus_fallback() -> Result<oo7::Keyring, Box<oo7::Error>> {
529 match oo7::Keyring::new().await {
530 Ok(keyring) => Ok(keyring),
531 Err(oo7::Error::File(ref file_err)) => {
532 log::info!(
535 "oo7 portal backend unavailable ({}), trying D-Bus Secret Service fallback",
536 file_err
537 );
538 let service = oo7::dbus::Service::new()
539 .await
540 .map_err(|e| Box::new(oo7::Error::from(e)))?;
541 let collection = service
542 .default_collection()
543 .await
544 .map_err(|e| Box::new(oo7::Error::from(e)))?;
545 Ok(oo7::Keyring::DBus(collection))
546 }
547 Err(e) => Err(Box::new(e)),
548 }
549}
550
551fn keyring_error_message(e: &oo7::Error) -> &'static str {
554 match e {
555 oo7::Error::File(_) => {
556 "Your desktop's Secret portal is not configured.\n\
557 This typically happens on Hyprland, Sway, XFCE, and other \
558 non-GNOME/KDE desktops.\n\n\
559 To fix this, ensure gnome-keyring (or kwallet) is installed \
560 and add an entry for org.freedesktop.impl.portal.Secret in \
561 your portal configuration.\n\n\
562 See: https://github.com/nicx17/mimick/wiki/Keyring-Setup"
563 }
564 oo7::Error::DBus(_) => {
565 "The D-Bus Secret Service is not available.\n\
566 Ensure gnome-keyring-daemon or KWallet is installed \
567 and running.\n\n\
568 See: https://github.com/nicx17/mimick/wiki/Keyring-Setup"
569 }
570 }
571}
572
573fn keyring_error_hint(e: &oo7::Error) -> &'static str {
575 match e {
576 oo7::Error::File(_) => "portal backend error",
577 oo7::Error::DBus(_) => "D-Bus Secret Service error",
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use std::path::Path;
585
586 #[test]
587 fn test_watch_path_entry_parsing_simple() {
588 let json = r#""/home/user/Pictures""#;
589 let entry: WatchPathEntry = serde_json::from_str(json).unwrap();
590
591 assert_eq!(entry.path(), "/home/user/Pictures");
592 assert!(matches!(entry, WatchPathEntry::Simple(_)));
593 }
594
595 #[test]
596 fn test_watch_path_entry_parsing_with_config() {
597 let json = r#"{
598 "path": "/home/user/Pictures",
599 "album_id": "abc-123",
600 "album_name": "My Album"
601 }"#;
602 let entry: WatchPathEntry = serde_json::from_str(json).unwrap();
603
604 assert_eq!(entry.path(), "/home/user/Pictures");
605 let WatchPathEntry::WithConfig { album_id, .. } = &entry else {
606 panic!("expected configured watch entry");
607 };
608 assert_eq!(album_id.as_deref(), Some("abc-123"));
609 assert_eq!(entry.album_name().unwrap(), "My Album");
610 }
611
612 #[test]
613 fn test_config_data_defaults() {
614 let data = ConfigData::default();
615 assert!(data.internal_url_enabled);
616 assert!(data.external_url_enabled);
617 assert!(!data.background_sync_enabled);
618 }
619
620 #[test]
621 fn test_config_data_background_sync_defaults_false_when_missing() {
622 let data: ConfigData = serde_json::from_str("{}").unwrap();
623 assert!(!data.background_sync_enabled);
624 }
625
626 #[test]
627 fn test_legacy_folder_rules_default_to_upload_only_and_global_startup_fallback() {
628 let entry: WatchPathEntry = serde_json::from_str(
629 r#"{
630 "path": "/home/nick/Pictures",
631 "rules": {
632 "ignore_hidden": true
633 }
634 }"#,
635 )
636 .unwrap();
637
638 let rules = entry.rules();
639 assert_eq!(rules.sync_method, FolderSyncMethod::UploadOnly);
640 assert_eq!(
641 entry.startup_catchup_mode(&StartupCatchupMode::RecentOnly),
642 StartupCatchupMode::RecentOnly
643 );
644 assert!(!rules.delete_folder_to_album);
645 assert!(!rules.delete_album_to_folder);
646 }
647
648 #[test]
649 fn test_watch_path_strings_helper() {
650 let mut data = ConfigData::default();
651 data.watch_paths.push(WatchPathEntry::Simple("/a".into()));
652 data.watch_paths.push(WatchPathEntry::WithConfig {
653 path: "/b".into(),
654 album_id: None,
655 album_name: None,
656 rules: FolderRules::default(),
657 });
658
659 let config = Config {
660 data,
661 config_file: PathBuf::from("dummy.json"),
662 };
663
664 let strings = config.watch_path_strings();
665 assert_eq!(strings, vec!["/a".to_string(), "/b".to_string()]);
666 }
667
668 #[test]
669 fn test_folder_rules_match_extension_and_size() {
670 let dir = tempfile::tempdir().unwrap();
671 let path = dir.path().join("photo.jpg");
672 fs::write(&path, vec![0u8; 1024]).unwrap();
673
674 let rules = FolderRules {
675 ignore_hidden: false,
676 max_file_size_mb: Some(1),
677 allowed_extensions: vec!["jpg".into(), "png".into()],
678 ..FolderRules::default()
679 };
680
681 assert!(rules.matches(&path));
682
683 let restricted = FolderRules {
684 ignore_hidden: false,
685 max_file_size_mb: Some(0),
686 allowed_extensions: vec!["png".into()],
687 ..FolderRules::default()
688 };
689
690 assert!(!restricted.matches(&path));
691 }
692
693 #[test]
694 fn test_folder_rules_ignore_hidden_path_components() {
695 let dir = tempfile::tempdir().unwrap();
696 let hidden_dir = dir.path().join(".hidden");
697 fs::create_dir_all(&hidden_dir).unwrap();
698 let hidden_file = hidden_dir.join("photo.jpg");
699 fs::write(&hidden_file, vec![0u8; 16]).unwrap();
700
701 let rules = FolderRules {
702 ignore_hidden: true,
703 ..FolderRules::default()
704 };
705
706 assert!(!rules.matches(&hidden_file));
707 }
708
709 #[test]
710 fn test_normalized_extensions_trims_and_lowercases() {
711 let rules = FolderRules {
712 allowed_extensions: vec![" JPG ".into(), ".PNG".into(), "".into()],
713 ..FolderRules::default()
714 };
715
716 assert_eq!(rules.normalized_extensions(), vec!["jpg", "png"]);
717 }
718
719 #[test]
720 fn test_best_matching_watch_entry_prefers_most_specific_path() {
721 let entries = vec![
722 WatchPathEntry::Simple("/home/user/Pictures".into()),
723 WatchPathEntry::WithConfig {
724 path: "/home/user/Pictures/Trips".into(),
725 album_id: Some("album-1".into()),
726 album_name: Some("Trips".into()),
727 rules: FolderRules::default(),
728 },
729 ];
730
731 let matched = best_matching_watch_entry(
732 Path::new("/home/user/Pictures/Trips/day1/photo.jpg"),
733 &entries,
734 )
735 .unwrap();
736 assert_eq!(matched.path(), "/home/user/Pictures/Trips");
737 assert_eq!(matched.album_name(), Some("Trips"));
738 }
739
740 #[test]
741 fn test_best_matching_watch_entry_does_not_match_string_prefix_siblings() {
742 let entries = vec![WatchPathEntry::Simple("/home/user/Pictures".into())];
743
744 assert!(
745 best_matching_watch_entry(Path::new("/home/user/Pictures-backup/photo.jpg"), &entries)
746 .is_none()
747 );
748 }
749
750 #[test]
751 fn face_visibility_defaults_favour_discoverable_named_only() {
752 let data = ConfigData::default();
753 assert!(
754 data.show_unnamed_faces,
755 "unnamed people should be visible by default so users see them"
756 );
757 assert!(
758 !data.show_hidden_faces,
759 "hidden people stay hidden by default"
760 );
761 }
762
763 #[test]
764 fn face_visibility_flags_round_trip_through_json() {
765 let data = ConfigData {
766 show_unnamed_faces: false,
767 show_hidden_faces: true,
768 ..ConfigData::default()
769 };
770 let json = serde_json::to_string(&data).expect("serialize");
771 let restored: ConfigData = serde_json::from_str(&json).expect("deserialize");
772 assert!(!restored.show_unnamed_faces);
773 assert!(restored.show_hidden_faces);
774 }
775
776 #[test]
777 fn face_visibility_flags_default_when_absent_in_json() {
778 let json = serde_json::to_string(&serde_json::json!({})).unwrap();
780 let restored: ConfigData = serde_json::from_str(&json).expect("deserialize legacy config");
781 assert!(restored.show_unnamed_faces);
782 assert!(!restored.show_hidden_faces);
783 }
784}