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
7 changes: 7 additions & 0 deletions src-tauri/src/app_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,22 @@ pub struct SortCriteria {
#[serde(rename_all = "camelCase")]
pub struct FilterCriteria {
pub rating: u8,
#[serde(default = "default_rejected_status")]
pub rejected_status: String,
pub raw_status: String,
#[serde(default)]
pub colors: Vec<String>,
}

fn default_rejected_status() -> String {
"all".to_string()
}

impl Default for FilterCriteria {
fn default() -> Self {
Self {
rating: 0,
rejected_status: default_rejected_status(),
raw_status: "all".to_string(),
colors: Vec::new(),
}
Expand Down
8 changes: 4 additions & 4 deletions src-tauri/src/file_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ pub struct ImageFile {
path: String,
modified: u64,
is_edited: bool,
rating: u8,
rating: i8,
tags: Option<Vec<String>>,
exif: Option<HashMap<String, String>>,
is_virtual_copy: bool,
Expand Down Expand Up @@ -1058,7 +1058,7 @@ fn generate_single_thumbnail_and_cache(
preloaded_image: Option<&DynamicImage>,
force_regenerate: bool,
app_handle: &AppHandle,
) -> Option<(String, u8)> {
) -> Option<(String, i8)> {
let (source_path, sidecar_path) = parse_virtual_path(path_str);

let img_mod_time = fs::metadata(source_path)
Expand Down Expand Up @@ -2012,7 +2012,7 @@ pub fn set_color_label_for_paths(
#[tauri::command]
pub fn set_rating_for_paths(
paths: Vec<String>,
rating: u8,
rating: i8,
app_handle: AppHandle,
) -> Result<(), String> {
let settings = load_settings(app_handle.clone()).unwrap_or_default();
Expand Down Expand Up @@ -2970,7 +2970,7 @@ pub fn create_virtual_copy(source_virtual_path: String) -> Result<String, String
Ok(new_virtual_path)
}

pub fn extract_xmp_rating(content: &str) -> Option<u8> {
pub fn extract_xmp_rating(content: &str) -> Option<i8> {
if let Some(idx) = content.find("xmp:Rating=\"") {
let start = idx + 12;
let end = content[start..].find('"').map(|i| start + i)?;
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/image_processing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ impl<'a> IntoCowImage<'a> for &'a std::sync::Arc<DynamicImage> {
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ImageMetadata {
pub version: u32,
pub rating: u8,
pub rating: i8,
pub adjustments: Value,
#[serde(default)]
pub tags: Option<Vec<String>>,
Expand Down
145 changes: 128 additions & 17 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import {
} from 'lucide-react';
import TitleBar from './window/TitleBar';
import CommunityPage from './components/panel/CommunityPage';
import MainLibrary, { ColumnWidths } from './components/panel/MainLibrary';
import MainLibrary, { ColumnWidths, SelectByCriteria } from './components/panel/MainLibrary';
import FolderTree from './components/panel/FolderTree';
import Editor from './components/panel/Editor';
import Controls from './components/panel/right/ControlsPanel';
Expand Down Expand Up @@ -119,6 +119,8 @@ import {
ThumbnailSize,
ThumbnailAspectRatio,
CullingSuggestions,
RejectedFilterStatus,
REJECTED_RATING,
} from './components/ui/AppProperties';
import { ChannelConfig } from './components/adjustments/Curves';
import HdrModal from './components/modals/HdrModal';
Expand Down Expand Up @@ -307,6 +309,7 @@ function App() {
const [filterCriteria, setFilterCriteria] = useState<FilterCriteria>({
colors: [],
rating: 0,
rejectedStatus: RejectedFilterStatus.All,
rawStatus: RawStatus.All,
});
const [supportedTypes, setSupportedTypes] = useState<SupportedTypes | null>(null);
Expand Down Expand Up @@ -1284,15 +1287,24 @@ function App() {
}

const filteredList = processedList.filter((image) => {
const rating = imageRatings[image.path] ?? 0;

if (filterCriteria.rating > 0) {
const rating = imageRatings[image.path] || 0;
if (filterCriteria.rating === 5) {
if (rating !== 5) return false;
} else {
if (rating < filterCriteria.rating) return false;
}
}

if (filterCriteria.rejectedStatus === RejectedFilterStatus.RejectedOnly && rating !== REJECTED_RATING) {
return false;
}

if (filterCriteria.rejectedStatus === RejectedFilterStatus.UnrejectedOnly && rating === REJECTED_RATING) {
return false;
}

if (
filterCriteria.rawStatus &&
filterCriteria.rawStatus !== RawStatus.All &&
Expand Down Expand Up @@ -1929,6 +1941,7 @@ function App() {
setFilterCriteria((prev: FilterCriteria) => ({
...prev,
...settings.filterCriteria,
rejectedStatus: settings.filterCriteria.rejectedStatus || RejectedFilterStatus.All,
rawStatus: settings.filterCriteria.rawStatus || RawStatus.All,
colors: settings.filterCriteria.colors || [],
}));
Expand Down Expand Up @@ -2909,31 +2922,66 @@ function App() {
}
};

const getRatingTargetPaths = useCallback(
(paths?: Array<string>) =>
paths ||
(multiSelectedPaths.length > 0
? multiSelectedPaths
: selectedImage
? [selectedImage.path]
: libraryActivePath
? [libraryActivePath]
: []),
[libraryActivePath, multiSelectedPaths, selectedImage],
);

const applyRatingToPaths = useCallback((pathsToRate: string[], nextRating: number) => {
if (pathsToRate.length === 0) {
return;
}

setImageRatings((prev: Record<string, number>) => {
const newRatings = { ...prev };
pathsToRate.forEach((path: string) => {
newRatings[path] = nextRating;
});
return newRatings;
});

invoke(Invokes.SetRatingForPaths, { paths: pathsToRate, rating: nextRating }).catch((err) => {
console.error('Failed to apply rating to paths:', err);
setError(`Failed to apply rating: ${err}`);
});
}, []);

const handleRate = useCallback(
(newRating: number, paths?: Array<string>) => {
const pathsToRate =
paths || (multiSelectedPaths.length > 0 ? multiSelectedPaths : selectedImage ? [selectedImage.path] : []);
const pathsToRate = getRatingTargetPaths(paths);
if (pathsToRate.length === 0) {
return;
}

const currentRating = imageRatings[pathsToRate[0]] || 0;
const finalRating = newRating === currentRating ? 0 : newRating;

setImageRatings((prev: Record<string, number>) => {
const newRatings = { ...prev };
pathsToRate.forEach((path: string) => {
newRatings[path] = finalRating;
});
return newRatings;
});
applyRatingToPaths(pathsToRate, finalRating);
},
[applyRatingToPaths, getRatingTargetPaths, imageRatings],
);

invoke(Invokes.SetRatingForPaths, { paths: pathsToRate, rating: finalRating }).catch((err) => {
console.error('Failed to apply rating to paths:', err);
setError(`Failed to apply rating: ${err}`);
});
const handleToggleRejected = useCallback(
(paths?: Array<string>) => {
const pathsToRate = getRatingTargetPaths(paths);
if (pathsToRate.length === 0) {
return;
}

const allRejected = pathsToRate.every((path: string) => (imageRatings[path] ?? 0) === REJECTED_RATING);
const finalRating = allRejected ? 0 : REJECTED_RATING;

applyRatingToPaths(pathsToRate, finalRating);
},
[multiSelectedPaths, selectedImage, imageRatings],
[applyRatingToPaths, getRatingTargetPaths, imageRatings],
);

const handleUpdateExif = useCallback(
Expand Down Expand Up @@ -3421,6 +3469,7 @@ function App() {
handlePasteAdjustments,
handlePasteFiles,
handleRate,
handleToggleRejected,
handleRightPanelSelect,
handleRotate,
handleSetColorLabel,
Expand Down Expand Up @@ -4291,6 +4340,57 @@ function App() {
}
};

const handleSelectBy = useCallback(
(criteria: SelectByCriteria) => {
const matchingPaths = sortedImageList
.filter((image: ImageFile) => {
if (criteria.type === 'rating') {
const rating = imageRatings[image.path] ?? 0;

if (criteria.mode === 'rejected') {
return rating === REJECTED_RATING;
}

if (criteria.mode === 'notRejected') {
return rating !== REJECTED_RATING;
}

if (criteria.mode === 'unrated') {
return rating === 0;
}

const threshold = criteria.value ?? 0;
if (rating === REJECTED_RATING) {
return false;
}
return threshold === 5 ? rating === 5 : rating >= threshold;
}

const imageColor = (image.tags || []).find((tag: string) => tag.startsWith('color:'))?.substring(6);
return imageColor === criteria.color;
})
.map((image: ImageFile) => image.path);

setMultiSelectedPaths(matchingPaths);

if (selectedImage) {
if (matchingPaths.length > 0) {
handleImageSelect(matchingPaths[0]);
setSelectionAnchorPath(matchingPaths[0]);
} else {
handleBackToLibrary();
setSelectionAnchorPath(null);
}
return;
}

const nextActivePath = matchingPaths[0] ?? null;
setLibraryActivePath(nextActivePath);
setSelectionAnchorPath(nextActivePath);
},
[sortedImageList, imageRatings, selectedImage, handleImageSelect, handleBackToLibrary],
);

const handleRenameFiles = useCallback(async (paths: Array<string>) => {
if (paths && paths.length > 0) {
setRenameTargetPaths(paths);
Expand Down Expand Up @@ -4611,6 +4711,11 @@ function App() {
onClick: () => handleRate(rating),
})),
},
{
label: 'Rejected',
icon: X,
onClick: () => handleToggleRejected(),
},
{
label: 'Color Label',
icon: Palette,
Expand Down Expand Up @@ -5008,6 +5113,11 @@ function App() {
onClick: () => handleRate(rating, finalSelection),
})),
},
{
label: 'Rejected',
icon: X,
onClick: () => handleToggleRejected(finalSelection),
},
{
label: 'Color Label',
icon: Palette,
Expand Down Expand Up @@ -5369,6 +5479,7 @@ function App() {
onImportClick={() => handleImportClick(currentFolderPath as string)}
onLibraryRefresh={handleLibraryRefresh}
onOpenFolder={handleOpenFolder}
onSelectBy={handleSelectBy}
onSettingsChange={handleSettingsChange}
onThumbnailAspectRatioChange={setThumbnailAspectRatio}
onThumbnailSizeChange={setThumbnailSize}
Expand Down Expand Up @@ -6015,7 +6126,7 @@ function App() {
thumbnails={thumbnails}
onApply={(action, paths) => {
if (action === 'reject') {
handleSetColorLabel('red', paths);
applyRatingToPaths(paths, REJECTED_RATING);
} else if (action === 'rate_zero') {
handleRate(1, paths);
} else if (action === 'delete') {
Expand Down
2 changes: 1 addition & 1 deletion src/components/modals/CullingModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const CULL_ACTIONS: {
label: string;
icon: React.ReactNode;
}[] = [
{ value: 'reject', label: 'Mark as Rejected (Red Label)', icon: <Tag size={16} className="text-red-500" /> },
{ value: 'reject', label: 'Mark as Rejected', icon: <Tag size={16} className="text-red-500" /> },
{ value: 'rate_zero', label: 'Set Rating to 1 Stars', icon: <Star size={16} /> },
{ value: 'delete', label: 'Move to Trash', icon: <Trash2 size={16} /> },
];
Expand Down
Loading
Loading