1use crate::schema::{
2 ALL_RESOURCE_EXTENSIONS, CUSTOM_COLUMN, DEPLOYMENT_ID_COLUMN, EVENT_ID_COLUMN,
3 IMAGE_EXTENSIONS, PATH_COLUMN, RATING_COLUMN, VIDEO_EXTENSIONS, XMP_EXTENSIONS,
4 resource_extension, underlying_media_path,
5};
6use core::fmt;
7use indicatif::{ProgressBar, ProgressStyle};
8use pest_derive::Parser;
9use polars::prelude::*;
10use rayon::prelude::*;
11use std::collections::HashSet;
12use std::ffi::OsString;
13use std::fs::{File, FileTimes};
14use std::io;
15use std::str::FromStr;
16use std::{
17 env, fs,
18 path::{Path, PathBuf},
19 sync::Arc,
20};
21use walkdir::{DirEntry, WalkDir};
22use xmp_toolkit::{OpenFileOptions, XmpFile, XmpMeta};
23
24pub fn csv_projection_columns(names: &[&str]) -> Option<Arc<[PlSmallStr]>> {
25 Some(Arc::from(
26 names
27 .iter()
28 .map(|name| PlSmallStr::from(*name))
29 .collect::<Vec<_>>()
30 .into_boxed_slice(),
31 ))
32}
33
34pub fn reject_duplicate_csv_columns(df: &DataFrame) -> anyhow::Result<()> {
35 if df
36 .get_column_names()
37 .iter()
38 .any(|name| name.as_str().contains("_duplicated_"))
39 {
40 return Err(anyhow::anyhow!(
41 "Duplicated CSV columns detected. Please check the input CSV header."
42 ));
43 }
44
45 Ok(())
46}
47
48#[derive(Parser)]
49#[grammar = "filter.pest"]
50struct FilterParser;
51
52#[derive(clap::ValueEnum, Clone, Copy, Debug)]
53pub enum ResourceType {
54 Xmp,
55 Image,
56 Video,
57 Media, All, }
60
61impl fmt::Display for ResourceType {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 write!(f, "{self:?}")
64 }
65}
66
67impl ResourceType {
68 fn extension(self) -> &'static [&'static str] {
69 match self {
70 ResourceType::Image => IMAGE_EXTENSIONS,
71 ResourceType::Video => VIDEO_EXTENSIONS,
72 ResourceType::Xmp => XMP_EXTENSIONS,
73 ResourceType::Media => crate::schema::MEDIA_EXTENSIONS,
74 ResourceType::All => ALL_RESOURCE_EXTENSIONS,
75 }
76 }
77
78 fn is_resource(self, path: &Path) -> bool {
79 resource_extension(path).is_some_and(|ext| self.extension().contains(&ext.as_str()))
80 }
81}
82
83#[derive(clap::ValueEnum, PartialEq, Clone, Copy, Debug)]
84pub enum TagType {
85 Species,
86 Individual,
87 Count,
88 Sex,
89 Bodypart,
90}
91
92impl fmt::Display for TagType {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 write!(f, "{self:?}")
95 }
96}
97
98impl TagType {
99 pub fn col_name(self) -> &'static str {
100 match self {
101 TagType::Individual => "individual",
102 TagType::Species => "species",
103 TagType::Count => "count",
104 TagType::Sex => "sex",
105 TagType::Bodypart => "bodypart",
106 }
107 }
108 pub fn digikam_tag_prefix(self) -> &'static str {
109 match self {
110 TagType::Individual => "Individual/",
111 TagType::Species => "Species/",
112 TagType::Count => "Count/",
113 TagType::Sex => "Sex/",
114 TagType::Bodypart => "Bodypart/",
115 }
116 }
117 pub fn adobe_tag_prefix(self) -> &'static str {
118 match self {
119 TagType::Individual => "Individual|",
120 TagType::Species => "Species|",
121 TagType::Count => "Count|",
122 TagType::Sex => "Sex|",
123 TagType::Bodypart => "Bodypart|",
124 }
125 }
126}
127
128#[derive(clap::ValueEnum, PartialEq, Clone, Copy, Debug)]
129pub enum XmpUpdateType {
130 Species,
131 Individual,
132 Rating,
133}
134
135impl fmt::Display for XmpUpdateType {
136 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137 write!(f, "{self:?}")
138 }
139}
140
141impl XmpUpdateType {
142 pub fn col_name(self) -> &'static str {
143 match self {
144 Self::Species => TagType::Species.col_name(),
145 Self::Individual => TagType::Individual.col_name(),
146 Self::Rating => RATING_COLUMN,
147 }
148 }
149
150 pub fn tag_type(self) -> Option<TagType> {
151 match self {
152 Self::Species => Some(TagType::Species),
153 Self::Individual => Some(TagType::Individual),
154 Self::Rating => None,
155 }
156 }
157}
158
159#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq)]
160pub enum ExtractFilterType {
161 Species,
162 Path,
163 Individual,
164 Rating,
165 Event,
166 Custom,
167 Advanced,
168}
169
170#[derive(clap::ValueEnum, Clone, Copy, Debug)]
171pub enum SubdirType {
172 Species,
173 Individual,
174 Rating,
175 Custom,
176}
177
178#[derive(Debug, Clone)]
180pub struct FilterCondition {
181 pub filter_type: ExtractFilterType,
182 pub operator: FilterOperator,
183 pub value: String,
184}
185
186#[derive(Debug, Clone)]
188pub enum FilterOperator {
189 Equal, GreaterEqual, LessEqual, Greater, Less, Range(f64, f64), }
198
199#[derive(Debug, Clone)]
201pub enum LogicalOperator {
202 And,
203 Or,
204}
205
206#[derive(Debug, Clone)]
208pub enum FilterExpr {
209 Condition(FilterCondition),
210 Logical {
211 left: Box<FilterExpr>,
212 operator: LogicalOperator,
213 right: Box<FilterExpr>,
214 },
215 }
217
218impl ExtractFilterType {
219 pub fn from_alias(alias: &str) -> Option<Self> {
221 match alias.to_lowercase().as_str() {
222 "species" | "sp" | "s" => Some(Self::Species),
223 "individual" | "ind" | "i" => Some(Self::Individual),
224 "rating" | "rate" | "r" => Some(Self::Rating),
225 "path" | "p" => Some(Self::Path),
226 "event" | "e" => Some(Self::Event),
227 "custom" | "c" => Some(Self::Custom),
228 _ => None,
229 }
230 }
231}
232
233pub fn parse_advanced_filter(input: &str) -> anyhow::Result<FilterExpr> {
235 use pest::Parser;
236
237 let pairs = FilterParser::parse(Rule::filter, input)
238 .map_err(|e| anyhow::anyhow!("Parse error: {e}"))?;
239
240 let or_expr = pairs
242 .into_iter()
243 .next()
244 .ok_or_else(|| anyhow::anyhow!("Empty parse result"))?
245 .into_inner()
246 .next()
247 .ok_or_else(|| anyhow::anyhow!("No expression found"))?;
248
249 build_expr(or_expr)
250}
251
252fn build_expr(pair: pest::iterators::Pair<Rule>) -> anyhow::Result<FilterExpr> {
254 match pair.as_rule() {
255 Rule::or_expr => {
256 let mut inner = pair.into_inner();
257 let mut expr = build_expr(inner.next().unwrap())?;
258
259 while let Some(next) = inner.next() {
260 if next.as_rule() == Rule::or_op {
261 let right = build_expr(inner.next().unwrap())?;
262 expr = FilterExpr::Logical {
263 left: Box::new(expr),
264 operator: LogicalOperator::Or,
265 right: Box::new(right),
266 };
267 }
268 }
269
270 Ok(expr)
271 }
272
273 Rule::and_expr => {
274 let mut inner = pair.into_inner();
275 let mut expr = build_expr(inner.next().unwrap())?;
276
277 while let Some(next) = inner.next() {
278 if next.as_rule() == Rule::and_op {
279 let right = build_expr(inner.next().unwrap())?;
280 expr = FilterExpr::Logical {
281 left: Box::new(expr),
282 operator: LogicalOperator::And,
283 right: Box::new(right),
284 };
285 }
286 }
287
288 Ok(expr)
289 }
290
291 Rule::primary => {
292 let inner = pair.into_inner().next().unwrap();
293 build_expr(inner)
294 }
295
296 Rule::paren_expr => {
297 let inner = pair.into_inner().next().unwrap();
298 build_expr(inner)
299 }
300
301 Rule::condition => {
302 let mut inner = pair.into_inner();
303 let field = inner.next().unwrap().as_str();
304 let value = inner.next().unwrap().as_str().trim(); let filter_type = ExtractFilterType::from_alias(field)
307 .ok_or_else(|| anyhow::anyhow!("Unknown filter field: {field}"))?;
308
309 let (operator, cleaned_value) = parse_value_and_operator(value)?;
310
311 Ok(FilterExpr::Condition(FilterCondition {
312 filter_type,
313 operator,
314 value: cleaned_value,
315 }))
316 }
317
318 _ => Err(anyhow::anyhow!("Unexpected rule: {:?}", pair.as_rule())),
319 }
320}
321
322fn parse_value_and_operator(value: &str) -> anyhow::Result<(FilterOperator, String)> {
324 if let Some((min_str, max_str)) = value.split_once('-')
326 && let (Ok(min), Ok(max)) = (min_str.trim().parse::<f64>(), max_str.trim().parse::<f64>())
327 {
328 return Ok((FilterOperator::Range(min, max), value.to_string()));
329 }
330
331 if let Some(stripped) = value.strip_prefix(">=") {
333 return Ok((FilterOperator::GreaterEqual, stripped.trim().to_string()));
334 }
335 if let Some(stripped) = value.strip_prefix("<=") {
336 return Ok((FilterOperator::LessEqual, stripped.trim().to_string()));
337 }
338 if let Some(stripped) = value.strip_prefix('>') {
339 return Ok((FilterOperator::Greater, stripped.trim().to_string()));
340 }
341 if let Some(stripped) = value.strip_prefix('<') {
342 return Ok((FilterOperator::Less, stripped.trim().to_string()));
343 }
344
345 let cleaned_value = if (value.starts_with('"') && value.ends_with('"'))
347 || (value.starts_with('\'') && value.ends_with('\''))
348 {
349 value[1..value.len() - 1].to_string()
350 } else {
351 value.to_string()
352 };
353
354 Ok((FilterOperator::Equal, cleaned_value))
356}
357
358pub fn has_same_field_and_conditions(expr: &FilterExpr) -> bool {
359 fn check(expr: &FilterExpr) -> (Vec<ExtractFilterType>, bool) {
364 match expr {
365 FilterExpr::Condition(cond) => (vec![cond.filter_type], false),
366 FilterExpr::Logical {
367 left,
368 operator,
369 right,
370 } => {
371 let (left_fields, left_dup) = check(left);
372 let (right_fields, right_dup) = check(right);
373 let dup = left_dup
376 || right_dup
377 || (matches!(operator, LogicalOperator::And)
378 && left_fields.iter().any(|f| right_fields.contains(f)));
379 let mut fields = left_fields;
380 fields.extend(right_fields);
381 (fields, dup)
382 }
383 }
384 }
385
386 check(expr).1
387}
388
389pub fn filter_expr_to_polars(expr: &FilterExpr, use_aggregated: bool) -> anyhow::Result<Expr> {
395 use crate::utils::TagType;
396
397 match expr {
398 FilterExpr::Condition(condition) => {
399 let col_name = match condition.filter_type {
400 ExtractFilterType::Species => TagType::Species.col_name(),
401 ExtractFilterType::Individual => TagType::Individual.col_name(),
402 ExtractFilterType::Rating => RATING_COLUMN,
403 ExtractFilterType::Path => PATH_COLUMN,
404 ExtractFilterType::Event => EVENT_ID_COLUMN,
405 ExtractFilterType::Custom => CUSTOM_COLUMN,
406 ExtractFilterType::Advanced => {
407 return Err(anyhow::anyhow!(
408 "Advanced filter should not appear in conditions"
409 ));
410 }
411 };
412
413 let base_col = col(col_name);
414
415 match &condition.operator {
416 FilterOperator::Equal => {
417 if condition.filter_type == ExtractFilterType::Path {
418 Ok(base_col
420 .str()
421 .contains_literal(lit(condition.value.clone())))
422 } else if use_aggregated
423 && (condition.filter_type == ExtractFilterType::Species
424 || condition.filter_type == ExtractFilterType::Individual)
425 {
426 Ok(base_col
428 .list()
429 .contains(lit(condition.value.clone()), false))
430 } else {
431 Ok(base_col.eq(lit(condition.value.clone())))
432 }
433 }
434 FilterOperator::Range(min, max) => {
435 let numeric_col = base_col.cast(DataType::Float64);
437 Ok(numeric_col
438 .clone()
439 .is_not_null()
440 .and(numeric_col.clone().gt_eq(lit(*min)))
441 .and(numeric_col.lt_eq(lit(*max))))
442 }
443 FilterOperator::GreaterEqual => {
444 if let Ok(value) = condition.value.parse::<f64>() {
445 let numeric_col = base_col.cast(DataType::Float64);
446 Ok(numeric_col
447 .clone()
448 .is_not_null()
449 .and(numeric_col.gt_eq(lit(value))))
450 } else {
451 Err(anyhow::anyhow!(
452 "GreaterEqual operator requires numeric value"
453 ))
454 }
455 }
456 FilterOperator::LessEqual => {
457 if let Ok(value) = condition.value.parse::<f64>() {
458 let numeric_col = base_col.cast(DataType::Float64);
459 Ok(numeric_col
460 .clone()
461 .is_not_null()
462 .and(numeric_col.lt_eq(lit(value))))
463 } else {
464 Err(anyhow::anyhow!("LessEqual operator requires numeric value"))
465 }
466 }
467 FilterOperator::Greater => {
468 if let Ok(value) = condition.value.parse::<f64>() {
469 let numeric_col = base_col.cast(DataType::Float64);
470 Ok(numeric_col
471 .clone()
472 .is_not_null()
473 .and(numeric_col.gt(lit(value))))
474 } else {
475 Err(anyhow::anyhow!("Greater operator requires numeric value"))
476 }
477 }
478 FilterOperator::Less => {
479 if let Ok(value) = condition.value.parse::<f64>() {
480 let numeric_col = base_col.cast(DataType::Float64);
481 Ok(numeric_col
482 .clone()
483 .is_not_null()
484 .and(numeric_col.lt(lit(value))))
485 } else {
486 Err(anyhow::anyhow!("Less operator requires numeric value"))
487 }
488 }
489 }
490 }
491 FilterExpr::Logical {
492 left,
493 operator,
494 right,
495 } => {
496 let left_expr = filter_expr_to_polars(left, use_aggregated)?;
497 let right_expr = filter_expr_to_polars(right, use_aggregated)?;
498
499 match operator {
500 LogicalOperator::And => Ok(left_expr.and(right_expr)),
501 LogicalOperator::Or => Ok(left_expr.or(right_expr)),
502 }
503 }
504 }
505}
506
507fn is_ignored(entry: &DirEntry) -> bool {
509 entry
510 .file_name()
511 .to_str()
512 .map(|s| s.starts_with('.') || s.contains("精选")) .unwrap_or(false)
514}
515
516pub fn serval_pb_style() -> ProgressStyle {
518 ProgressStyle::default_bar()
519 .template(
520 "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {wide_msg}",
521 )
522 .unwrap()
523 .progress_chars("=> ")
524}
525
526pub fn configure_progress_bar(pb: &ProgressBar) {
527 pb.set_style(serval_pb_style());
528 pb.enable_steady_tick(std::time::Duration::from_secs(1));
529}
530
531pub const SERVAL_OUTPUT_DIR: &str = "serval_output";
534
535static RUN_LOG: std::sync::OnceLock<(PathBuf, std::sync::Mutex<File>)> = std::sync::OnceLock::new();
536
537pub fn init_run_log(command: &str, log_dir: Option<&Path>) {
542 let log_dir = log_dir
543 .map(Path::to_path_buf)
544 .unwrap_or_else(|| PathBuf::from(format!("./{SERVAL_OUTPUT_DIR}/logs")));
545 let init = || -> anyhow::Result<(PathBuf, std::sync::Mutex<File>)> {
546 fs::create_dir_all(&log_dir)?;
547 let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
548 let log_path = log_dir.join(format!("serval_{command}_{timestamp}.log"));
549 let file = File::create(&log_path)?;
550 Ok((log_path, std::sync::Mutex::new(file)))
551 };
552 match init() {
553 Ok(entry) => {
554 let _ = RUN_LOG.set(entry);
555 log_line(&format!(
556 "Command: {}",
557 env::args().collect::<Vec<_>>().join(" ")
558 ));
559 }
560 Err(err) => eprintln!(
561 "Warning: failed to create run log in {}: {err}",
562 log_dir.display()
563 ),
564 }
565}
566
567pub fn run_log_path() -> Option<&'static Path> {
568 RUN_LOG.get().map(|(path, _)| path.as_path())
569}
570
571pub fn log_line(message: &str) {
573 if let Some((_, log)) = RUN_LOG.get()
574 && let Ok(mut file) = log.lock()
575 {
576 use std::io::Write;
577 let timestamp = chrono::Local::now().format("%H:%M:%S");
578 let _ = writeln!(file, "[{timestamp}] {message}");
579 }
580}
581
582pub fn pb_status(pb: &ProgressBar, message: impl Into<String>) {
585 let message = message.into();
586 log_line(&message);
587 if pb.is_hidden() {
588 println!("{message}");
589 } else {
590 pb.set_message(message);
591 }
592}
593
594#[derive(Default)]
597pub struct WarningCollector {
598 count: std::sync::atomic::AtomicUsize,
599}
600
601impl WarningCollector {
602 pub fn warn(&self, pb: &ProgressBar, message: impl Into<String>) {
605 let message = message.into();
606 log_line(&format!("Warning: {message}"));
607 if pb.is_hidden() {
608 eprintln!("Warning: {message}");
609 } else {
610 pb.println(format!("Warning: {message}"));
611 }
612 self.count
613 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
614 }
615
616 pub fn warn_plain(&self, message: impl Into<String>) {
618 let message = message.into();
619 log_line(&format!("Warning: {message}"));
620 eprintln!("Warning: {message}");
621 self.count
622 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
623 }
624
625 pub fn summarize(&self) {
626 let count = self.count.load(std::sync::atomic::Ordering::Relaxed);
627 if count > 0 {
628 log_line(&format!("{count} warning(s) occurred"));
629 eprintln!("{count} warning(s) occurred, see messages above.");
630 }
631 }
632}
633
634fn path_to_absolute(path: PathBuf) -> io::Result<PathBuf> {
637 if path.is_absolute() {
638 return Ok(path);
639 }
640 let path = path.strip_prefix(".").unwrap_or(&path);
641 env::current_dir().map(|current_dir| current_dir.join(path))
642}
643
644pub fn absolute_path(path: PathBuf) -> io::Result<PathBuf> {
645 let path_buf = path_to_absolute(path)?;
646 #[cfg(windows)]
647 let path_buf = Path::new(
648 path_buf
649 .as_path()
650 .to_string_lossy()
651 .trim_start_matches(r"\\?\"),
652 )
653 .to_path_buf();
654 Ok(path_buf)
655}
656
657pub fn path_enumerate(root_dir: PathBuf, resource_type: ResourceType) -> Vec<PathBuf> {
658 WalkDir::new(root_dir)
659 .into_iter()
660 .filter_entry(|e| !is_ignored(e))
661 .par_bridge()
662 .filter_map(Result::ok)
663 .filter(|e| resource_type.is_resource(e.path()))
664 .map(|e| e.into_path())
665 .collect()
666}
667
668pub fn dedup_output_path(path: PathBuf) -> PathBuf {
671 if !path.exists() {
672 return path;
673 }
674 let stem = path
675 .file_stem()
676 .map(|stem| stem.to_string_lossy().into_owned())
677 .unwrap_or_default();
678 let extension = path
679 .extension()
680 .map(|ext| ext.to_string_lossy().into_owned());
681 let mut i = 1;
682 loop {
683 let file_name = match &extension {
684 Some(ext) => format!("{stem}_{i}.{ext}"),
685 None => format!("{stem}_{i}"),
686 };
687 let candidate = path.with_file_name(file_name);
688 if !candidate.exists() {
689 return candidate;
690 }
691 i += 1;
692 }
693}
694
695pub fn resources_flatten(
696 deploy_dir: PathBuf,
697 working_dir: PathBuf,
698 resource_type: ResourceType,
699 dry_run: bool,
700 move_mode: bool,
701 prefix_deploy_id_in_name: bool,
702 keep_first_subdir: bool,
703) -> anyhow::Result<()> {
704 let deploy_id = deploy_dir
705 .file_name()
706 .ok_or_else(|| anyhow::anyhow!("Invalid deploy directory path: no filename"))?;
707
708 let base_output_dir = working_dir.join(deploy_id);
709 fs::create_dir_all(base_output_dir.clone())?;
710
711 let resource_paths = path_enumerate(deploy_dir.clone(), resource_type);
712 let num_resource = resource_paths.len();
713 println!(
714 "{} {}(s) found in {}",
715 num_resource,
716 resource_type,
717 deploy_dir.to_string_lossy()
718 );
719
720 let mut visited_path: HashSet<String> = HashSet::new();
721 let pb = if !dry_run {
722 Some(indicatif::ProgressBar::new(num_resource as u64))
723 } else {
724 None
725 };
726 if let Some(pb_ref) = &pb {
727 configure_progress_bar(pb_ref);
728 }
729 for resource in resource_paths {
730 let resource_parent = resource.parent().unwrap();
731 let relative_path = resource.strip_prefix(&deploy_dir).unwrap_or(&resource);
732 let mut relative_parts: Vec<OsString> = relative_path
733 .iter()
734 .map(|part| part.to_os_string())
735 .collect();
736 if relative_parts.is_empty() {
737 relative_parts.push("unnamed_file".into());
738 }
739
740 let mut output_dir = base_output_dir.clone();
741 if keep_first_subdir && relative_parts.len() > 1 {
742 output_dir = output_dir.join(&relative_parts[0]);
743 if !dry_run {
744 fs::create_dir_all(output_dir.clone())?;
745 }
746 }
747
748 let mut name_parts: Vec<OsString> = Vec::new();
749 if prefix_deploy_id_in_name {
750 name_parts.push(deploy_id.to_os_string());
751 }
752 name_parts.extend(relative_parts);
753 let resource_name = name_parts.join(std::ffi::OsStr::new("-"));
754
755 let output_path = output_dir.join(resource_name);
756
757 if !dry_run {
758 let final_output_path = dedup_output_path(output_path.clone());
760 if final_output_path != output_path {
761 let message = format!(
762 "Renamed to {} to avoid overwriting",
763 final_output_path.display()
764 );
765 log_line(&message);
766 if let Some(pb_ref) = &pb {
767 pb_ref.println(message);
768 }
769 }
770 log_line(&format!(
771 "{} {} -> {}",
772 if move_mode { "Moving" } else { "Copying" },
773 resource.display(),
774 final_output_path.display()
775 ));
776 if move_mode {
777 fs::rename(resource, final_output_path)?;
778 } else {
779 fs::copy(resource, final_output_path)?;
780 }
781 if let Some(pb_ref) = &pb {
782 pb_ref.inc(1);
783 }
784 } else if !visited_path.contains(resource_parent.to_string_lossy().as_ref()) {
785 visited_path.insert(resource_parent.to_string_lossy().to_string());
786 println!(
787 "DRYRUN sample: From {} to {}",
788 resource.display(),
789 output_path.display()
790 );
791 }
792 }
793 if let Some(pb_ref) = pb {
794 pb_ref.finish();
795 }
796 Ok(())
797}
798
799pub fn deployments_align(
800 project_dir: PathBuf,
801 output_dir: PathBuf,
802 deploy_table: PathBuf,
803 resource_type: ResourceType,
804 dry_run: bool,
805 move_mode: bool,
806 keep_first_subdir: bool,
807) -> anyhow::Result<()> {
808 let deploy_df = CsvReadOptions::default()
809 .with_columns(csv_projection_columns(&[DEPLOYMENT_ID_COLUMN]))
810 .try_into_reader_with_file_path(Some(deploy_table))?
811 .finish()?;
812 reject_duplicate_csv_columns(&deploy_df)?;
813 let deploy_df = deploy_df
814 .lazy()
815 .select([col(DEPLOYMENT_ID_COLUMN)])
816 .collect()?;
817 let deploy_array = deploy_df[DEPLOYMENT_ID_COLUMN].str()?;
818
819 let deploy_iter = deploy_array.iter();
820 let num_iter = deploy_iter.len();
821 let pb = indicatif::ProgressBar::new(num_iter as u64);
822 configure_progress_bar(&pb);
823 for deploy_id in deploy_iter {
824 let deploy_id = deploy_id
825 .ok_or_else(|| anyhow::anyhow!("Empty deploymentID found in the deployments table"))?;
826 let (_, collection_name) = deploy_id.rsplit_once('_').ok_or_else(|| {
827 anyhow::anyhow!(
828 "Invalid deploymentID '{deploy_id}': expected '<deployment_name>_<collection_name>'"
829 )
830 })?;
831 let deploy_dir = project_dir.join(collection_name).join(deploy_id);
832 let collection_output_dir = output_dir.join(collection_name);
833 resources_flatten(
834 deploy_dir,
835 collection_output_dir.clone(),
836 resource_type,
837 dry_run,
838 move_mode,
839 true,
840 keep_first_subdir,
841 )?;
842 pb.inc(1);
843 }
844 pb.finish();
845 Ok(())
846}
847
848pub fn deployments_rename(project_dir: PathBuf, dry_run: bool) -> anyhow::Result<()> {
849 let mut count = 0;
851 for entry in project_dir.read_dir()? {
852 let entry = entry?;
853 let path = entry.path();
854 if path.is_dir() {
855 if path.file_name().and_then(|name| name.to_str()) == Some(SERVAL_OUTPUT_DIR) {
857 continue;
858 }
859 let mut collection_dir = path;
860 let original_collection_name = collection_dir
861 .file_name()
862 .and_then(|name| name.to_str())
863 .ok_or_else(|| anyhow::anyhow!("Invalid collection directory name"))?;
864 let collection_name_lower = original_collection_name.to_lowercase();
865 if original_collection_name != collection_name_lower {
866 let mut new_collection_dir = collection_dir.clone();
867 new_collection_dir.set_file_name(&collection_name_lower);
868 if dry_run {
869 println!(
870 "Will rename collection {original_collection_name} to {collection_name_lower}"
871 );
872 } else {
873 let message = format!(
874 "Renaming collection {} to {}",
875 collection_dir.display(),
876 new_collection_dir.display()
877 );
878 log_line(&message);
879 println!("{message}");
880 fs::rename(&collection_dir, &new_collection_dir)?;
881 collection_dir = new_collection_dir;
882 }
883 }
884 let collection_name = collection_dir
885 .file_name()
886 .and_then(|name| name.to_str())
887 .ok_or_else(|| anyhow::anyhow!("Invalid collection directory name"))?;
888 for deploy in collection_dir.read_dir()? {
889 let deploy_dir = deploy?.path();
890 if deploy_dir.is_file() {
891 continue;
892 }
893 count += 1;
894 let deploy_name = deploy_dir
895 .file_name()
896 .and_then(|name| name.to_str())
897 .ok_or_else(|| anyhow::anyhow!("Invalid deploy directory name"))?;
898 if !deploy_name.contains(collection_name) {
899 if dry_run {
900 println!(
901 "Will rename {} to {}_{}",
902 deploy_name,
903 deploy_name.to_lowercase(),
904 collection_name.to_lowercase()
905 );
906 } else {
907 let mut deploy_id_dir = deploy_dir.clone();
908 deploy_id_dir.set_file_name(format!(
909 "{}_{}",
910 deploy_name.to_lowercase(),
911 collection_name.to_lowercase()
912 ));
913 let message = format!(
914 "Renaming {} to {}",
915 deploy_dir.display(),
916 deploy_id_dir.display()
917 );
918 log_line(&message);
919 println!("{message}");
920 fs::rename(deploy_dir, deploy_id_dir)?;
921 }
922 }
923 }
924 }
925 }
926 println!("Total directories: {count}");
927 Ok(())
928}
929
930pub fn copy_xmp(source_dir: PathBuf, output_dir: PathBuf) -> anyhow::Result<()> {
932 let xmp_paths = path_enumerate(source_dir.clone(), ResourceType::Xmp);
933 let num_xmp = xmp_paths.len();
934 println!("{num_xmp} xmp files found");
935 let pb = indicatif::ProgressBar::new(num_xmp as u64);
936 configure_progress_bar(&pb);
937
938 for xmp in xmp_paths {
939 let mut output_path = output_dir.clone();
940 let relative_path = xmp.strip_prefix(&source_dir).unwrap();
941 output_path.push(relative_path);
942 fs::create_dir_all(output_path.parent().unwrap())?;
943 fs::copy(xmp, output_path)?;
944 pb.inc(1);
945 }
946 pb.finish();
947 Ok(())
948}
949
950pub enum BatchOutcome {
952 Done,
953 Skipped(String),
954}
955
956pub fn report_batch_results(results: Vec<anyhow::Result<BatchOutcome>>, action: &str) {
959 let mut done = 0;
960 let mut skipped = Vec::new();
961 let mut failures = Vec::new();
962 for result in results {
963 match result {
964 Ok(BatchOutcome::Done) => done += 1,
965 Ok(BatchOutcome::Skipped(reason)) => skipped.push(reason),
966 Err(err) => failures.push(err),
967 }
968 }
969 for reason in &skipped {
970 log_line(&format!("Warning: {reason}"));
971 eprintln!("Warning: {reason}");
972 }
973 for err in &failures {
974 log_line(&format!("Error: {err}"));
975 eprintln!("Error: {err}");
976 }
977 let summary = format!(
978 "{done} XMP file(s) {action}, {} skipped, {} failed",
979 skipped.len(),
980 failures.len()
981 );
982 log_line(&summary);
983 println!("{summary}");
984}
985
986pub fn sync_xmp_to_media(xmp_path: &Path) -> anyhow::Result<BatchOutcome> {
988 let media_path = underlying_media_path(xmp_path);
989 if media_path == xmp_path {
990 return Ok(BatchOutcome::Skipped(format!(
991 "Skipping non-XMP file: {}",
992 xmp_path.display()
993 )));
994 }
995
996 if !media_path.exists() {
997 return Ok(BatchOutcome::Skipped(format!(
998 "Skipping {}: media file {} does not exist",
999 xmp_path.display(),
1000 media_path.display()
1001 )));
1002 }
1003
1004 let xmp_content = fs::read_to_string(xmp_path)?;
1005 let xmp_meta = XmpMeta::from_str(&xmp_content)?;
1006
1007 let mut xmp_file = XmpFile::new()?;
1008 let open_options = OpenFileOptions::default().for_update();
1009 xmp_file.open_file(media_path, open_options)?;
1010 xmp_file.put_xmp(&xmp_meta)?;
1011 xmp_file.try_close()?;
1012
1013 Ok(BatchOutcome::Done)
1014}
1015
1016pub fn sync_xmp_directory(source_dir: PathBuf) -> anyhow::Result<()> {
1017 let xmp_paths = path_enumerate(source_dir.clone(), ResourceType::Xmp);
1018 let num_xmp = xmp_paths.len();
1019
1020 if num_xmp == 0 {
1021 println!("No XMP files found in {}", source_dir.display());
1022 return Ok(());
1023 }
1024
1025 println!(
1026 "Found {} XMP files to sync in {}",
1027 num_xmp,
1028 source_dir.display()
1029 );
1030
1031 let pb = indicatif::ProgressBar::new(num_xmp as u64);
1032 configure_progress_bar(&pb);
1033 pb.set_message("Syncing XMP metadata to media files...");
1034
1035 let results: Vec<anyhow::Result<BatchOutcome>> = xmp_paths
1036 .par_iter()
1037 .map(|xmp_path| {
1038 let result = sync_xmp_to_media(xmp_path);
1039 pb.inc(1);
1040 result
1041 })
1042 .collect();
1043
1044 pb.finish();
1045 report_batch_results(results, "synced");
1046
1047 Ok(())
1048}
1049
1050pub fn sync_xmp_from_csv(csv_path: PathBuf) -> anyhow::Result<()> {
1051 let df = CsvReadOptions::default()
1052 .with_columns(csv_projection_columns(&[PATH_COLUMN]))
1053 .with_ignore_errors(false)
1054 .try_into_reader_with_file_path(Some(csv_path))?
1055 .finish()?;
1056 reject_duplicate_csv_columns(&df)?;
1057
1058 let df_filtered = df
1059 .lazy()
1060 .filter(col("path").is_not_null())
1061 .filter(col("path").str().ends_with(lit(".xmp")))
1062 .select([col("path")])
1063 .unique(
1064 Some(cols(vec!["path".to_string()])),
1065 UniqueKeepStrategy::First,
1066 )
1067 .collect()?;
1068
1069 let num_files = df_filtered.height();
1070 if num_files == 0 {
1071 println!("No XMP files found in CSV");
1072 return Ok(());
1073 }
1074
1075 println!("Found {num_files} XMP files in CSV to sync");
1076
1077 let pb = indicatif::ProgressBar::new(num_files as u64);
1078 configure_progress_bar(&pb);
1079 pb.set_message("Syncing XMP files in CSV...");
1080
1081 let path_col = df_filtered.column("path")?.str()?;
1082
1083 let results: Vec<anyhow::Result<BatchOutcome>> = path_col
1084 .par_iter()
1085 .filter_map(|path| path.map(PathBuf::from))
1086 .map(|xmp_path| {
1087 let result = sync_xmp_to_media(&xmp_path);
1088 pb.inc(1);
1089 result
1090 })
1091 .collect();
1092
1093 pb.finish();
1094 report_batch_results(results, "synced");
1095
1096 Ok(())
1097}
1098
1099pub fn remove_xmp_files(source_dir: PathBuf) -> anyhow::Result<()> {
1101 let xmp_paths = path_enumerate(source_dir.clone(), ResourceType::Xmp);
1102 let num_xmp = xmp_paths.len();
1103
1104 if num_xmp == 0 {
1105 println!("No XMP files found in {}", source_dir.display());
1106 return Ok(());
1107 }
1108
1109 println!("Found {} XMP files in {}", num_xmp, source_dir.display());
1110
1111 let pb = indicatif::ProgressBar::new(num_xmp as u64);
1112 configure_progress_bar(&pb);
1113 pb.set_message("Removing XMP files...");
1114
1115 let results: Vec<anyhow::Result<BatchOutcome>> = xmp_paths
1116 .par_iter()
1117 .map(|xmp_path| {
1118 let result = fs::remove_file(xmp_path)
1119 .map(|_| BatchOutcome::Done)
1120 .map_err(|e| anyhow::anyhow!("Failed to remove {}: {}", xmp_path.display(), e));
1121 pb.inc(1);
1122 result
1123 })
1124 .collect();
1125
1126 pb.finish();
1127 report_batch_results(results, "removed");
1128 Ok(())
1129}
1130
1131pub fn get_path_levels(path: String) -> Vec<String> {
1132 let normalized_path = normalize_path_separators(&path);
1136 let levels: Vec<String> = normalized_path
1137 .split('/')
1138 .map(|comp| comp.to_string())
1139 .collect();
1140 if levels.len() < 2 {
1141 return Vec::new();
1142 }
1143 levels[1..levels.len() - 1].to_vec()
1144}
1145
1146fn normalize_path_separators(path: &str) -> String {
1147 path.replace('\\', "/")
1148}
1149
1150pub fn detect_deployment_path_index<I, S>(paths: I) -> Option<i32>
1155where
1156 I: IntoIterator<Item = S>,
1157 S: AsRef<str>,
1158{
1159 let mut level_names: Vec<HashSet<String>> = Vec::new();
1160 let mut depth = None;
1161 for path in paths {
1162 let normalized = normalize_path_separators(path.as_ref());
1163 let components: Vec<&str> = normalized.split('/').collect();
1164 if components.len() < 3 {
1166 return None;
1167 }
1168 match depth {
1169 None => {
1170 depth = Some(components.len());
1171 level_names = vec![HashSet::new(); components.len() - 2];
1172 }
1173 Some(depth) if depth != components.len() => return None,
1175 Some(_) => {}
1176 }
1177 for (level, name) in components[1..components.len() - 1].iter().enumerate() {
1178 if !level_names[level].contains(*name) {
1179 level_names[level].insert((*name).to_string());
1180 }
1181 }
1182 }
1183 let diverge_level = level_names.iter().position(|names| names.len() > 1)?;
1185 let deploy_level = if diverge_level + 1 < level_names.len()
1186 && level_names[diverge_level + 1].len() > level_names[diverge_level].len()
1187 {
1188 diverge_level + 1
1189 } else {
1190 diverge_level
1191 };
1192 (deploy_level + 1).try_into().ok()
1194}
1195
1196pub fn deployment_from_path(path: &Path, deploy_path_index: i32) -> anyhow::Result<String> {
1197 let normalized_path = normalize_path_separators(&path.to_string_lossy());
1198 normalized_path
1199 .split('/')
1200 .nth(deploy_path_index.try_into()?)
1201 .map(str::to_string)
1202 .ok_or_else(|| {
1203 anyhow::anyhow!(
1204 "Cannot extract deployment from path '{}' with index {}.",
1205 path.display(),
1206 deploy_path_index
1207 )
1208 })
1209}
1210
1211pub fn deployment_from_path_expr(path_expr: Expr, deploy_path_index: i32) -> Expr {
1212 path_expr
1213 .str()
1214 .replace_all(lit("\\"), lit("/"), true)
1215 .str()
1216 .split(lit("/"))
1217 .list()
1218 .get(lit(deploy_path_index), false)
1219}
1220
1221pub fn ignore_timezone(time: String) -> anyhow::Result<String> {
1222 let time = time.trim_end_matches('Z');
1223 let time_start = time.find(['T', ' ']).map_or(0, |i| i + 1);
1226 let tz_start = time[time_start..]
1227 .find(['+', '-', '.'])
1228 .map_or(time.len(), |i| time_start + i);
1229 Ok(time[..tz_start].to_string())
1230}
1231
1232pub fn iso_datetime_to_csv_format(time: &str) -> String {
1233 time.replace('T', " ")
1234}
1235
1236pub fn sync_modified_time(source: PathBuf, target: PathBuf) -> anyhow::Result<()> {
1237 let src = fs::metadata(source)?;
1238 let dest = File::options().write(true).open(target)?;
1239 let times = FileTimes::new()
1240 .set_accessed(src.accessed()?)
1241 .set_modified(src.modified()?);
1242 dest.set_times(times)?;
1243 Ok(())
1244}
1245
1246pub fn tags_csv_translate(
1247 source_csv: PathBuf,
1248 taglist_csv: PathBuf,
1249 output_dir: PathBuf,
1250 from: &str,
1251 to: &str,
1252) -> anyhow::Result<()> {
1253 let source_df = CsvReadOptions::default()
1254 .with_infer_schema_length(Some(0))
1255 .try_into_reader_with_file_path(Some(source_csv.clone()))?
1256 .finish()?;
1257 reject_duplicate_csv_columns(&source_df)?;
1258 let taglist_df = CsvReadOptions::default()
1259 .with_columns(csv_projection_columns(&[from, to]))
1260 .try_into_reader_with_file_path(Some(taglist_csv))?
1261 .finish()?;
1262 reject_duplicate_csv_columns(&taglist_df)?;
1263
1264 let joined = source_df.lazy().join(
1265 taglist_df.lazy(),
1266 [col(TagType::Species.col_name())],
1267 [col(from)],
1268 JoinArgs::new(JoinType::Left),
1269 );
1270
1271 let unknown = joined
1272 .clone()
1273 .filter(
1274 col(to)
1275 .is_null()
1276 .and(col(TagType::Species.col_name()).is_not_null())
1277 .and(col(TagType::Species.col_name()).neq(lit(""))),
1278 )
1279 .select([col(TagType::Species.col_name())])
1280 .unique(None, UniqueKeepStrategy::Any)
1281 .collect()?;
1282 if unknown.height() > 0 {
1283 let mut sample = Vec::new();
1284 if let Ok(col) = unknown.column(TagType::Species.col_name())
1285 && let Ok(ca) = col.str()
1286 {
1287 for v in ca.iter().flatten().take(20) {
1288 sample.push(v.to_string());
1289 }
1290 }
1291 return Err(anyhow::anyhow!(
1292 "Unknown tag(s) not found in taglist: {}",
1293 sample.join(", ")
1294 ));
1295 }
1296
1297 let mut result = joined
1298 .drop(cols([TagType::Species.col_name()]))
1299 .rename(vec![to], vec![TagType::Species.col_name()], true)
1300 .collect()?;
1302
1303 let output_csv = output_dir.join(format!(
1304 "{}_translated.csv",
1305 source_csv
1306 .file_stem()
1307 .and_then(|stem| stem.to_str())
1308 .unwrap_or("tags")
1309 ));
1310 fs::create_dir_all(output_dir.clone())?;
1311 let mut file = std::fs::File::create(&output_csv)?;
1312 CsvWriter::new(&mut file)
1313 .include_bom(true)
1314 .finish(&mut result)?;
1315
1316 println!("Saved to {}", output_csv.display());
1317 Ok(())
1318}
1319
1320#[cfg(test)]
1321mod tests {
1322 use super::*;
1323
1324 #[test]
1325 fn ignore_timezone_strips_timezone_suffixes() {
1326 let strip = |s: &str| ignore_timezone(s.to_string()).unwrap();
1327 assert_eq!(strip("2023-12-08T10:47:39+08:00"), "2023-12-08T10:47:39");
1328 assert_eq!(strip("2023-12-08T10:47:39-08:00"), "2023-12-08T10:47:39");
1329 assert_eq!(strip("2023-12-08T10:47:39Z"), "2023-12-08T10:47:39");
1330 assert_eq!(strip("2023-12-08T10:47:39"), "2023-12-08T10:47:39");
1331 assert_eq!(
1332 strip("2023-12-08T10:47:39.123+08:00"),
1333 "2023-12-08T10:47:39"
1334 );
1335 assert_eq!(strip("2023-12-08 10:47:39-0800"), "2023-12-08 10:47:39");
1336 }
1337
1338 #[test]
1339 fn detect_deployment_path_index_top_down() {
1340 assert_eq!(
1342 detect_deployment_path_index([
1343 "project/col_a/dep1_col_a/IMG_0001.jpg",
1344 "project/col_a/dep2_col_a/IMG_0001.jpg",
1345 "project/col_b/dep3_col_b/IMG_0002.jpg",
1346 ]),
1347 Some(2)
1348 );
1349 assert_eq!(
1351 detect_deployment_path_index([
1352 "project/col_a/dep1/100MEDIA/IMG_0001.jpg",
1353 "project/col_a/dep2/100MEDIA/IMG_0001.jpg",
1354 ]),
1355 Some(2)
1356 );
1357 assert_eq!(
1359 detect_deployment_path_index(["data/dep1/IMG_0001.jpg", "data/dep2/IMG_0001.jpg"]),
1360 Some(1)
1361 );
1362 assert_eq!(
1364 detect_deployment_path_index([
1365 r"project\col_a\dep1\IMG_0001.jpg",
1366 r"project\col_a\dep2\IMG_0001.jpg",
1367 ]),
1368 Some(2)
1369 );
1370 assert_eq!(
1372 detect_deployment_path_index(["project/col_a/dep1/a.jpg", "project/col_a/dep1/b.jpg"]),
1373 None
1374 );
1375 assert_eq!(
1377 detect_deployment_path_index([
1378 "project/col_a/dep1/a.jpg",
1379 "project/col_a/dep2/100MEDIA/b.jpg",
1380 ]),
1381 None
1382 );
1383 assert_eq!(detect_deployment_path_index(["dep1/a.jpg"]), None);
1385 assert_eq!(detect_deployment_path_index(Vec::<String>::new()), None);
1386 }
1387
1388 #[test]
1389 fn advanced_filter_detects_same_field_and_conditions() {
1390 let needs_agg =
1391 |input: &str| has_same_field_and_conditions(&parse_advanced_filter(input).unwrap());
1392 assert!(needs_agg("species:A and species:B"));
1393 assert!(needs_agg("(species:A and species:B) or rating:5"));
1394 assert!(needs_agg("species:A and (species:B or rating:5)"));
1395 assert!(needs_agg("(species:A or rating:5) and species:B"));
1396 assert!(!needs_agg("species:A or species:B"));
1397 assert!(!needs_agg("species:A and rating:4-5"));
1398 assert!(!needs_agg("(species:A or rating:5) and custom:x"));
1399 }
1400}