Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe491ec427 |
@@ -49,6 +49,7 @@ pub type Result<T> = std::result::Result<T, NominaError>;
|
||||
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(),
|
||||
|
||||
@@ -33,14 +33,33 @@ impl Pipeline {
|
||||
self.steps.push(PipelineStep { rule, mode });
|
||||
}
|
||||
|
||||
pub fn preview(&self, files: &[crate::FileEntry]) -> Vec<PreviewResult> {
|
||||
pub fn preview(&self, files: &[crate::FileEntry], shared_numbering: bool) -> Vec<PreviewResult> {
|
||||
// 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<PreviewResult> = 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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ pub async fn preview_rename(
|
||||
directory: String,
|
||||
filters: Option<FilterConfig>,
|
||||
selected_paths: Option<Vec<String>>,
|
||||
shared_numbering: Option<bool>,
|
||||
) -> Result<Vec<PreviewResult>, 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<PathBuf, _> = 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]
|
||||
|
||||
@@ -410,7 +410,7 @@ function PipelineCard({ rule, index, onScrollToCenter }: { rule: PipelineRule; i
|
||||
e.preventDefault();
|
||||
requestAnimationFrame(() => {
|
||||
const el = cardRef.current?.closest("[data-slot='popover']")?.querySelector<HTMLElement>(
|
||||
".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();
|
||||
});
|
||||
|
||||
@@ -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<NumberingConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
@@ -74,7 +77,14 @@ export function NumberingConfig({ ruleId }: { ruleId: string }) {
|
||||
<Checkbox checked={rule.reverse} onCheckedChange={(c) => update({ reverse: !!c })} />
|
||||
Reverse order
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={sharedNumbering} onCheckedChange={(c) => setSharedNumbering(!!c)} />
|
||||
Shared numbering
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground -mt-1">
|
||||
Shared numbering counts files and folders together. When off, they get separate sequences.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
|
||||
// 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<PreviewResult[]>("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 }) {
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
spellCheck={false}
|
||||
className="w-full min-h-[360px] max-h-[600px] rounded-lg border border-input bg-transparent p-3 text-xs font-mono leading-relaxed resize-y outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 dark:bg-input/30"
|
||||
placeholder={"One filename per line...\nLine 1 = first selected file\nLine 2 = second selected file\n..."}
|
||||
|
||||
@@ -182,12 +182,15 @@ export const useRuleStore = create<RuleState>((set, get) => ({
|
||||
}
|
||||
|
||||
try {
|
||||
const { filters } = useFileStore.getState();
|
||||
const { filters, selectedFiles } = useFileStore.getState();
|
||||
const selected = selectedFiles.size > 0 ? Array.from(selectedFiles) : null;
|
||||
const sharedNumbering = useSettingsStore.getState().sharedNumbering;
|
||||
const results = await invoke<PreviewResult[]>("preview_rename", {
|
||||
rules: activeRules,
|
||||
directory,
|
||||
filters,
|
||||
selectedPaths: null,
|
||||
selectedPaths: selected,
|
||||
sharedNumbering,
|
||||
});
|
||||
useFileStore.getState().setPreviewResults(results);
|
||||
} catch (e) {
|
||||
|
||||
@@ -39,6 +39,7 @@ interface SettingsState {
|
||||
skipReadOnly: boolean;
|
||||
conflictStrategy: ConflictStrategy;
|
||||
caseSensitiveMatch: boolean;
|
||||
sharedNumbering: boolean;
|
||||
|
||||
// pipeline
|
||||
autoPreviewDelay: number;
|
||||
@@ -96,6 +97,7 @@ interface SettingsState {
|
||||
setSkipReadOnly: (v: boolean) => void;
|
||||
setConflictStrategy: (v: ConflictStrategy) => void;
|
||||
setCaseSensitiveMatch: (v: boolean) => void;
|
||||
setSharedNumbering: (v: boolean) => void;
|
||||
setAutoPreviewDelay: (v: number) => void;
|
||||
setShowDisabledRules: (v: DisabledRuleDisplay) => void;
|
||||
setCreateBackups: (v: boolean) => void;
|
||||
@@ -167,6 +169,7 @@ const defaults = {
|
||||
skipReadOnly: true,
|
||||
conflictStrategy: "suffix" as ConflictStrategy,
|
||||
caseSensitiveMatch: false,
|
||||
sharedNumbering: false,
|
||||
autoPreviewDelay: 150,
|
||||
showDisabledRules: "dimmed" as DisabledRuleDisplay,
|
||||
createBackups: false,
|
||||
@@ -218,6 +221,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setSkipReadOnly: (v) => set({ skipReadOnly: v }),
|
||||
setConflictStrategy: (v) => set({ conflictStrategy: v }),
|
||||
setCaseSensitiveMatch: (v) => set({ caseSensitiveMatch: v }),
|
||||
setSharedNumbering: (v) => set({ sharedNumbering: v }),
|
||||
setAutoPreviewDelay: (v) => set({ autoPreviewDelay: v }),
|
||||
setShowDisabledRules: (v) => set({ showDisabledRules: v }),
|
||||
setCreateBackups: (v) => set({ createBackups: v }),
|
||||
|
||||
Reference in New Issue
Block a user