Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src-tauri/src/file_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3261,6 +3261,22 @@ pub async fn import_files(
Ok(())
}

/// Make an EXIF text value safe to use as part of a filename: strip characters
/// that are invalid on Windows/Unix or could escape the target directory,
/// collapse whitespace, trim trailing dots, and cap the length.
fn sanitize_filename_component(value: &str) -> String {
let cleaned: String = value
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => ' ',
c if c.is_control() => ' ',
c => c,
})
.collect();
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
collapsed.trim_matches('.').trim().chars().take(100).collect()
}

pub fn generate_filename_from_template(
template: &str,
original_path: &std::path::Path,
Expand Down Expand Up @@ -3288,9 +3304,58 @@ pub fn generate_filename_from_template(
result = result.replace("{hh}", &local_date.format("%H").to_string());
result = result.replace("{mm}", &local_date.format("%M").to_string());

// Metadata tokens, named to match the Metadata panel's editable fields
// (Title / Author / Copyright / Comments). Read lazily (only when used) from
// the image's cached EXIF in the .rrdata sidecar, so unrelated callers pay no
// I/O cost.
if result.contains("{title}")
|| result.contains("{author}")
|| result.contains("{copyright}")
|| result.contains("{comments}")
{
let exif = original_path
.file_name()
.and_then(|n| n.to_str())
.map(|n| original_path.with_file_name(format!("{}.rrdata", n)))
.map(|sidecar| crate::exif_processing::load_sidecar(&sidecar).exif)
.unwrap_or_default()
.unwrap_or_default();
let lookup = |keys: &[&str]| -> String {
keys.iter()
.find_map(|k| exif.get(*k))
.map(|v| sanitize_filename_component(v))
.unwrap_or_default()
};
result = result.replace("{title}", &lookup(&["ImageDescription", "XPTitle"]));
result = result.replace("{author}", &lookup(&["Artist"]));
result = result.replace("{copyright}", &lookup(&["Copyright"]));
result = result.replace("{comments}", &lookup(&["UserComment", "XPComment"]));
}

result
}

/// Resolve a single export filename stem from a template for one image. Used by
/// the export panel to build the suggested name shown in the save dialog, so the
/// same tokens (dates, metadata, original filename) work for single-image export
/// as for batch export. Falls back to the original stem if the result is empty.
#[tauri::command]
pub fn generate_export_filename(path: String, template: String) -> String {
let (source_path, _) = parse_virtual_path(&path);
let file_date = crate::exif_processing::get_creation_date_from_path(&source_path);
let stem = generate_filename_from_template(&template, &source_path, 1, 1, &file_date);
let trimmed = stem.trim();
if trimmed.is_empty() {
source_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("image")
.to_string()
} else {
trimmed.to_string()
}
}

#[tauri::command]
pub fn rename_files(
paths: Vec<String>,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2279,6 +2279,7 @@ pub fn run() {
file_management::move_files,
file_management::rename_folder,
file_management::rename_files,
file_management::generate_export_filename,
file_management::duplicate_file,
file_management::show_in_finder,
file_management::delete_files_from_disk,
Expand Down
48 changes: 29 additions & 19 deletions src/components/panel/right/ExportPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -475,9 +475,19 @@ export default function ExportPanel({

let outputFolderOrFile = '';
if (numImages === 1) {
const originalFilename = pathsToExport[0].split(/[\\/]/).pop() || '';
const stem = originalFilename.substring(0, originalFilename.lastIndexOf('.')) || originalFilename;
const suggestedName = finalFilenameTemplate.replace('{original_filename}', stem);
let suggestedName: string;
try {
// Resolve the template (dates, metadata tokens, original filename) in the
// backend so single-image export supports the same tokens as batch.
suggestedName = await invoke<string>(Invokes.GenerateExportFilename, {
path: pathsToExport[0],
template: finalFilenameTemplate,
});
} catch {
const originalFilename = pathsToExport[0].split(/[\\/]/).pop() || '';
const stem = originalFilename.substring(0, originalFilename.lastIndexOf('.')) || originalFilename;
suggestedName = finalFilenameTemplate.replace('{original_filename}', stem);
}
const outputFileName = `${suggestedName}.${selectedFormat.extensions[0]}`;

outputFolderOrFile = isAndroid
Expand Down Expand Up @@ -607,18 +617,18 @@ export default function ExportPanel({
)}
</Section>

{numImages > 1 && (
<Section title={t('export.sections.fileNaming')}>
<input
className="w-full bg-surface border border-surface rounded-md p-2 text-sm text-text-primary focus:ring-accent focus:border-accent"
disabled={isExporting}
onChange={(e) => setFilenameTemplate(e.target.value)}
ref={filenameInputRef}
type="text"
value={filenameTemplate}
/>
<div className="flex flex-wrap gap-2 mt-2">
{FILENAME_VARIABLES.map((variable: string) => (
<Section title={t('export.sections.fileNaming')}>
<input
className="w-full bg-surface border border-surface rounded-md p-2 text-sm text-text-primary focus:ring-accent focus:border-accent"
disabled={isExporting}
onChange={(e) => setFilenameTemplate(e.target.value)}
ref={filenameInputRef}
type="text"
value={filenameTemplate}
/>
<div className="flex flex-wrap gap-2 mt-2">
{FILENAME_VARIABLES.filter((variable: string) => numImages > 1 || variable !== '{sequence}').map(
(variable: string) => (
<button
className="px-2 py-1 bg-surface text-text-secondary text-xs rounded-md hover:bg-card-active transition-colors disabled:opacity-50"
disabled={isExporting}
Expand All @@ -627,10 +637,10 @@ export default function ExportPanel({
>
{variable}
</button>
))}
</div>
</Section>
)}
),
)}
</div>
</Section>

{fileFormat !== FileFormats.Cube && (
<>
Expand Down
1 change: 1 addition & 0 deletions src/components/ui/AppProperties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export enum Invokes {
GenerateAiForegroundMask = 'generate_ai_foreground_mask',
GenerateAiSkyMask = 'generate_ai_sky_mask',
GenerateAiSubjectMask = 'generate_ai_subject_mask',
GenerateExportFilename = 'generate_export_filename',
GenerateFullscreenPreview = 'generate_fullscreen_preview',
GeneratePreviewForPath = 'generate_preview_for_path',
GenerateMaskOverlay = 'generate_mask_overlay',
Expand Down
21 changes: 21 additions & 0 deletions src/components/ui/ExportImportProperties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,34 @@ export const FILE_FORMATS: Array<FileFormat> = [
export const FILENAME_VARIABLES: Array<string> = [
'{original_filename}',
'{sequence}',
'{title}',
'{author}',
'{copyright}',
'{comments}',
'{YYYY}',
'{MM}',
'{DD}',
'{hh}',
'{mm}',
];

// The original author's default export filename template.
export const DEFAULT_FILENAME_TEMPLATE = '{original_filename}_edited';

// Guards against a persisted/imported template that references an unknown token
// (e.g. a stray "{dcp_title}" saved into the last-used preset): an unrecognized
// {token} never gets substituted and would leak into the output filename, so we
// fall back to the default instead.
export function sanitizeFilenameTemplate(template: string | null | undefined): string {
if (!template || !template.trim()) {
return DEFAULT_FILENAME_TEMPLATE;
}
const knownTokens = new Set(FILENAME_VARIABLES);
const usedTokens = template.match(/\{[^}]+\}/g) ?? [];
const hasUnknownToken = usedTokens.some((token) => !knownTokens.has(token));
return hasUnknownToken ? DEFAULT_FILENAME_TEMPLATE : template;
}

export interface ExportSettings {
filenameTemplate: string | null;
jpegQuality: number;
Expand Down
11 changes: 8 additions & 3 deletions src/hooks/useExportSettings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useState, useMemo, useCallback } from 'react';
import { ExportPreset, WatermarkAnchor } from '../components/ui/ExportImportProperties';
import {
DEFAULT_FILENAME_TEMPLATE,
ExportPreset,
sanitizeFilenameTemplate,
WatermarkAnchor,
} from '../components/ui/ExportImportProperties';

export function useExportSettings() {
const [fileFormat, setFileFormat] = useState('jpeg');
Expand All @@ -13,7 +18,7 @@ export function useExportSettings() {
const [stripGps, setStripGps] = useState(true);
const [exportMasks, setExportMasks] = useState(false);
const [preserveFolders, setPreserveFolders] = useState(false);
const [filenameTemplate, setFilenameTemplate] = useState('{original_filename}_edited');
const [filenameTemplate, setFilenameTemplate] = useState(DEFAULT_FILENAME_TEMPLATE);
const [enableWatermark, setEnableWatermark] = useState(false);
const [watermarkPath, setWatermarkPath] = useState<string | null>(null);
const [watermarkAnchor, setWatermarkAnchor] = useState<WatermarkAnchor>(WatermarkAnchor.BottomRight);
Expand All @@ -33,7 +38,7 @@ export function useExportSettings() {
setStripGps(preset.stripGps);
setExportMasks(preset.exportMasks ?? false);
setPreserveFolders(preset.preserveFolders ?? false);
setFilenameTemplate(preset.filenameTemplate);
setFilenameTemplate(sanitizeFilenameTemplate(preset.filenameTemplate));
setEnableWatermark(preset.enableWatermark);
setWatermarkPath(preset.watermarkPath);
setWatermarkAnchor(preset.watermarkAnchor as WatermarkAnchor);
Expand Down