diff --git a/crates/nomina-core/src/lib.rs b/crates/nomina-core/src/lib.rs index 0a38d7a..d837d37 100644 --- a/crates/nomina-core/src/lib.rs +++ b/crates/nomina-core/src/lib.rs @@ -49,6 +49,7 @@ pub type Result = std::result::Result; pub struct RenameContext { pub index: usize, pub total: usize, + pub absolute_index: usize, pub original_name: String, pub extension: String, pub path: PathBuf, @@ -65,6 +66,7 @@ impl RenameContext { Self { index, total: 1, + absolute_index: index, original_name: String::new(), extension: String::new(), path: PathBuf::new(), diff --git a/crates/nomina-core/src/pipeline.rs b/crates/nomina-core/src/pipeline.rs index c00101d..3546129 100644 --- a/crates/nomina-core/src/pipeline.rs +++ b/crates/nomina-core/src/pipeline.rs @@ -33,14 +33,33 @@ impl Pipeline { self.steps.push(PipelineStep { rule, mode }); } - pub fn preview(&self, files: &[crate::FileEntry]) -> Vec { + pub fn preview(&self, files: &[crate::FileEntry], shared_numbering: bool) -> Vec { + // precompute per-type counts and indices when using separate numbering + let dir_count = if shared_numbering { 0 } else { files.iter().filter(|f| f.is_dir).count() }; + let file_count = if shared_numbering { 0 } else { files.iter().filter(|f| !f.is_dir).count() }; + let mut dir_idx: usize = 0; + let mut file_idx: usize = 0; + let results: Vec = files .iter() .enumerate() .map(|(i, file)| { + let (index, total) = if shared_numbering { + (i, files.len()) + } else if file.is_dir { + let idx = dir_idx; + dir_idx += 1; + (idx, dir_count) + } else { + let idx = file_idx; + file_idx += 1; + (idx, file_count) + }; + let ctx = RenameContext { - index: i, - total: files.len(), + index, + total, + absolute_index: i, original_name: file.name.clone(), extension: file.extension.clone(), path: file.path.clone(), @@ -183,7 +202,7 @@ mod tests { ); let files = vec![make_file("IMG_001.jpg"), make_file("IMG_002.jpg")]; - let results = pipeline.preview(&files); + let results = pipeline.preview(&files, true); assert_eq!(results[0].new_name, "photo-001.jpg"); assert_eq!(results[1].new_name, "photo-002.jpg"); } @@ -215,7 +234,7 @@ mod tests { ); let files = vec![make_file("IMG_001.jpg")]; - let results = pipeline.preview(&files); + let results = pipeline.preview(&files, true); assert_eq!(results[0].new_name, "PHOTO-001.jpg"); } @@ -238,7 +257,7 @@ mod tests { ); let files = vec![make_file("same.txt"), make_file("same.txt")]; - let results = pipeline.preview(&files); + let results = pipeline.preview(&files, true); assert!(results[0].has_conflict); assert!(results[1].has_conflict); } diff --git a/crates/nomina-core/src/rules/text_editor.rs b/crates/nomina-core/src/rules/text_editor.rs index 07750f8..dca9050 100644 --- a/crates/nomina-core/src/rules/text_editor.rs +++ b/crates/nomina-core/src/rules/text_editor.rs @@ -20,7 +20,7 @@ impl TextEditorRule { impl RenameRule for TextEditorRule { fn apply(&self, filename: &str, context: &RenameContext) -> String { - if let Some(name) = self.names.get(context.index) { + if let Some(name) = self.names.get(context.absolute_index) { if !name.is_empty() { return name.clone(); } diff --git a/crates/nomina/src/commands/rename.rs b/crates/nomina/src/commands/rename.rs index 99f0ace..57273b9 100644 --- a/crates/nomina/src/commands/rename.rs +++ b/crates/nomina/src/commands/rename.rs @@ -97,6 +97,7 @@ pub async fn preview_rename( directory: String, filters: Option, selected_paths: Option>, + shared_numbering: Option, ) -> Result, String> { let scanner = FileScanner::new( PathBuf::from(&directory), @@ -104,13 +105,19 @@ pub async fn preview_rename( ); let all_files = scanner.scan(); - let files = if let Some(ref paths) = selected_paths { + let mut files = if let Some(ref paths) = selected_paths { let file_map: std::collections::HashMap = all_files.into_iter().map(|f| (f.path.clone(), f)).collect(); paths.iter().filter_map(|p| file_map.get(&PathBuf::from(p)).cloned()).collect() } else { all_files }; + // sort: folders before files, then case-insensitive alphabetical + files.sort_by(|a, b| { + b.is_dir.cmp(&a.is_dir) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + let mut pipeline = Pipeline::new(); for cfg in &rules { if let Some(rule) = build_rule(cfg) { @@ -127,7 +134,7 @@ pub async fn preview_rename( } } - Ok(pipeline.preview(&files)) + Ok(pipeline.preview(&files, shared_numbering.unwrap_or(false))) } #[tauri::command] diff --git a/ui/src/components/pipeline/PipelineStrip.tsx b/ui/src/components/pipeline/PipelineStrip.tsx index b77a6d3..b3df708 100644 --- a/ui/src/components/pipeline/PipelineStrip.tsx +++ b/ui/src/components/pipeline/PipelineStrip.tsx @@ -410,7 +410,7 @@ function PipelineCard({ rule, index, onScrollToCenter }: { rule: PipelineRule; i e.preventDefault(); requestAnimationFrame(() => { const el = cardRef.current?.closest("[data-slot='popover']")?.querySelector( - ".p-4 input, .p-4 select, .p-4 button, .p-4 [tabindex='0']" + ".p-4 textarea, .p-4 input, .p-4 select, .p-4 button, .p-4 [tabindex='0']" ); el?.focus(); }); diff --git a/ui/src/components/pipeline/configs/NumberingConfig.tsx b/ui/src/components/pipeline/configs/NumberingConfig.tsx index 9aaf892..9bf7b8e 100644 --- a/ui/src/components/pipeline/configs/NumberingConfig.tsx +++ b/ui/src/components/pipeline/configs/NumberingConfig.tsx @@ -1,4 +1,5 @@ import { useRuleStore } from "@/stores/ruleStore"; +import { useSettingsStore } from "@/stores/settingsStore"; import { Input } from "@/components/ui/input"; import { NumberInput } from "@/components/ui/number-input"; import { SegmentedControl } from "@/components/ui/segmented-control"; @@ -25,6 +26,8 @@ const bases = [ export function NumberingConfig({ ruleId }: { ruleId: string }) { const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as NumberingConfigType | undefined; const updateRule = useRuleStore((s) => s.updateRule); + const sharedNumbering = useSettingsStore((s) => s.sharedNumbering); + const setSharedNumbering = useSettingsStore((s) => s.setSharedNumbering); if (!rule) return null; const update = (changes: Partial) => updateRule(ruleId, changes); @@ -74,7 +77,14 @@ export function NumberingConfig({ ruleId }: { ruleId: string }) { update({ reverse: !!c })} /> Reverse order + +

+ Shared numbering counts files and folders together. When off, they get separate sequences. +

); } diff --git a/ui/src/components/pipeline/configs/TextEditorConfig.tsx b/ui/src/components/pipeline/configs/TextEditorConfig.tsx index 41d39c3..ebfe2d9 100644 --- a/ui/src/components/pipeline/configs/TextEditorConfig.tsx +++ b/ui/src/components/pipeline/configs/TextEditorConfig.tsx @@ -1,47 +1,77 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; import { useRuleStore } from "@/stores/ruleStore"; import { useFileStore } from "@/stores/fileStore"; +import { useSettingsStore } from "@/stores/settingsStore"; import { Button } from "@/components/ui/button"; import type { TextEditorConfig as TextEditorConfigType } from "@/types/rules"; +import type { PreviewResult } from "@/types/files"; export function TextEditorConfig({ ruleId }: { ruleId: string }) { - const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as TextEditorConfigType | undefined; + const pipeline = useRuleStore((s) => s.pipeline); + const rule = pipeline.find((r) => r.id === ruleId)?.config as TextEditorConfigType | undefined; const updateRule = useRuleStore((s) => s.updateRule); - const sortedFilePaths = useFileStore((s) => s.sortedFilePaths); + const currentPath = useFileStore((s) => s.currentPath); + const filters = useFileStore((s) => s.filters); const selectedFiles = useFileStore((s) => s.selectedFiles); - const files = useFileStore((s) => s.files); + const sharedNumbering = useSettingsStore((s) => s.sharedNumbering); const [text, setText] = useState(""); const [initialized, setInitialized] = useState(false); + const [intermediateStems, setIntermediateStems] = useState([]); - // get selected file stems in display order - const fileMap = new Map(files.map((f) => [f.path, f])); - const selectedStems = sortedFilePaths - .filter((p) => selectedFiles.has(p)) - .map((p) => fileMap.get(p)?.stem ?? ""); + // get rules before this one in the pipeline + const ruleIndex = pipeline.findIndex((r) => r.id === ruleId); + const rulesBefore = ruleIndex > 0 + ? pipeline.slice(0, ruleIndex).filter((r) => r.config.enabled).map((r) => r.config) + : []; + // compute intermediate names (after previous steps, before this one) + const fetchIntermediateNames = useCallback(async () => { + if (!currentPath) return []; + const selected = selectedFiles.size > 0 ? Array.from(selectedFiles) : null; + try { + const results = await invoke("preview_rename", { + rules: rulesBefore, + directory: currentPath, + filters, + selectedPaths: selected, + sharedNumbering, + }); + // extract stems from the intermediate results + const stems = results.map((r) => { + const name = r.new_name; + const lastDot = name.lastIndexOf("."); + return lastDot > 0 ? name.substring(0, lastDot) : name; + }); + setIntermediateStems(stems); + return stems; + } catch { + return []; + } + }, [currentPath, filters, selectedFiles, sharedNumbering, rulesBefore.length]); + + // fetch intermediate names on mount + useEffect(() => { + fetchIntermediateNames(); + }, [fetchIntermediateNames]); + + const fileCount = intermediateStems.length; const lineCount = text ? text.split("\n").length : 0; - const fileCount = selectedStems.length; const mismatch = lineCount !== fileCount && text.length > 0; - // populate textarea with current stems when first opened or when selection changes and user hasn't edited + // populate textarea with intermediate stems when first opened useEffect(() => { - if (!rule) return; - if (rule.names.length === 0 && !initialized) { - const joined = selectedStems.join("\n"); + if (!rule || initialized) return; + if (rule.names.length > 0) { + setText(rule.names.join("\n")); + setInitialized(true); + } else if (intermediateStems.length > 0) { + const joined = intermediateStems.join("\n"); setText(joined); setInitialized(true); } - }, [rule, selectedStems.length, initialized]); - - // sync rule names when first loaded with existing data - useEffect(() => { - if (!rule) return; - if (rule.names.length > 0 && !initialized) { - setText(rule.names.join("\n")); - setInitialized(true); - } - }, [rule, initialized]); + }, [rule, intermediateStems.length, initialized]); if (!rule) return null; @@ -51,10 +81,11 @@ export function TextEditorConfig({ ruleId }: { ruleId: string }) { updateRule(ruleId, { names }); }; - const handleLoad = () => { - const joined = selectedStems.join("\n"); + const handleLoad = async () => { + const stems = await fetchIntermediateNames(); + const joined = stems.join("\n"); setText(joined); - updateRule(ruleId, { names: selectedStems }); + updateRule(ruleId, { names: stems }); }; return ( @@ -76,6 +107,7 @@ export function TextEditorConfig({ ruleId }: { ruleId: string }) {