Add the duplicate originals review, from the sidebar and from Settings
"Duplicate originals" appears under the trash in the collections sidebar while the catalog holds any, and Settings says how many there are beside the other whole-library passes. Both open one page: every group with its picture and paths, the copy that stays (tap another path to change it), a per-group Include box, what the survivor will gain and any flag, label or face conflict, and why a group was skipped. The summary is the dry run -- "N groups, M files to trash, K skipped" -- and nothing moves until "Check" has read the copies and "Move M copies to trash" is pressed. Both run on workers with progress on the page, in the activity register and, for the move, on the library status line; Stop ends a job between groups. When it ends the grid, the sidebar and the trash are refreshed and the survivors' judgements are written to their sidecars and XMP the way a rating keystroke writes them. The page is paginated at 30 groups, so a redraw decodes 30 thumbnails and previews 30 merges whatever the size of the library. Back and Escape leave it like its own Back button.
This commit is contained in:
@@ -20,6 +20,7 @@ import { FocusMarks, FocusPanel, Peaking } from "peaking.slint";
|
||||
import { SettingsPage } from "settings.slint";
|
||||
import { ImportPage } from "import.slint";
|
||||
import { MergePage, MergeFrameRow } from "merge.slint";
|
||||
import { DuplicatesPage, Duplicates, DuplicateRow, DuplicateCopy } from "duplicates.slint";
|
||||
import { Capture, StatusBar, InfoPanel } from "develop.slint";
|
||||
import { ToolRail } from "toolrail.slint";
|
||||
import { Keys } from "keys.slint";
|
||||
@@ -36,6 +37,7 @@ export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Mask
|
||||
export { Collections }
|
||||
export { Library }
|
||||
export { ExportOptions }
|
||||
export { Duplicates, DuplicateRow, DuplicateCopy }
|
||||
|
||||
/// Which top-level screen is on display. Exactly one at a time — replacing
|
||||
/// the `show-launch` / `show-library` / `show-identity` booleans, with
|
||||
@@ -74,6 +76,9 @@ export enum Page {
|
||||
settings,
|
||||
import,
|
||||
merge,
|
||||
/// TRACES: FR-CAT-11a
|
||||
/// The review of duplicate originals.
|
||||
duplicates,
|
||||
}
|
||||
|
||||
export component AppWindow inherits Window {
|
||||
@@ -1145,6 +1150,11 @@ in property <bool> panel-visible: true;
|
||||
close() => { root.merge-close(); }
|
||||
}
|
||||
|
||||
if root.active-page == Page.duplicates: DuplicatesPage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
if root.active-page == Page.settings: SettingsPage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -1216,6 +1226,8 @@ in property <bool> panel-visible: true;
|
||||
thumbnail-library() => { Library.library-thumbnail-all(); }
|
||||
index-faces() => { root.identity-index(); }
|
||||
reindex-faces() => { root.identity-reindex(); }
|
||||
duplicate-groups: Duplicates.group-count;
|
||||
find-duplicates() => { Duplicates.open(); }
|
||||
|
||||
// TRACES: NFR-OPS-1
|
||||
diagnostics-preview: root.diagnostics-preview;
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
// release rather than the drop being silently discarded after it.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Duplicates } from "duplicates.slint";
|
||||
import { Keys } from "keys.slint";
|
||||
import { Button, Icon } from "widgets.slint";
|
||||
|
||||
@@ -865,6 +866,50 @@ export component CollectionsPanel inherits Rectangle {
|
||||
clicked => { root.empty-trash(); }
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-11a
|
||||
// GESTURE: Review duplicate originals
|
||||
// where: Collections sidebar
|
||||
// touch: Tap "Duplicate originals" under the trash
|
||||
// pointer: Click "Duplicate originals" under the trash
|
||||
// why: Under the trash because the trash is where the spare
|
||||
// copies go, and only while the catalog holds any: a row
|
||||
// that is always there and usually empty is noise.
|
||||
// manual: duplicate-originals
|
||||
if Duplicates.group-count > 0: Rectangle {
|
||||
height: Theme.row-height;
|
||||
background: dup-touch.has-hover ? Theme.hover : transparent;
|
||||
border-radius: Theme.radius-sm;
|
||||
|
||||
accessible-role: button;
|
||||
accessible-label: "Duplicate originals, " + Duplicates.group-count;
|
||||
accessible-action-default => { Duplicates.open(); }
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap-sm;
|
||||
padding-right: Theme.gap-sm;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Text {
|
||||
text: "Duplicate originals";
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: Duplicates.group-count;
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
dup-touch := TouchArea {
|
||||
clicked => { Duplicates.open(); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- error --------------------------------------------------------
|
||||
//
|
||||
// A refused drop or a failed rename says so here rather than only in
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// TRACES: FR-CAT-11a | FR-CAT-15 | FR-UI-4
|
||||
// The duplicate originals review: every group of copies of one file, which
|
||||
// one stays, what the others bring to it, and the one button that moves the
|
||||
// rest to the trash.
|
||||
//
|
||||
// Same shape as the merge page — a header with the one verb, a progress bar
|
||||
// under it while a job runs, a scrolling body — because it is the same kind
|
||||
// of page: a job proposes, the photographer confirms, and nothing moves
|
||||
// before the button is pressed. Every row is a tap target the height of a
|
||||
// finger, because the tablet uses this page too.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button, Caption, Value, ProgressBar } from "widgets.slint";
|
||||
import { Check } from "controls.slint";
|
||||
|
||||
/// One copy's line in a group.
|
||||
export struct DuplicateCopy {
|
||||
path: string,
|
||||
/// The copy that stays in the library.
|
||||
survivor: bool,
|
||||
}
|
||||
|
||||
/// One group of copies of one file.
|
||||
export struct DuplicateRow {
|
||||
/// "Canon EOS 6D · 24 Jun 2023 12:21 · 26.5 MB".
|
||||
title: string,
|
||||
thumb: image,
|
||||
has-thumb: bool,
|
||||
copies: [DuplicateCopy],
|
||||
/// What the check found: "Same file", "Not checked yet", or why it was
|
||||
/// left out.
|
||||
status: string,
|
||||
/// What the survivor gains, and any conflict.
|
||||
detail: string,
|
||||
/// Left out by the check, or failed.
|
||||
warn: bool,
|
||||
/// In the plan.
|
||||
include: bool,
|
||||
/// Whether the include box means anything: a group the check dropped
|
||||
/// cannot be put back by hand.
|
||||
can-include: bool,
|
||||
/// Consolidated in this session.
|
||||
done: bool,
|
||||
}
|
||||
|
||||
export global Duplicates {
|
||||
/// The current page of groups.
|
||||
in property <[DuplicateRow]> rows;
|
||||
/// The dry run: "1,836 groups · 3,379 copies to move to the trash · 2
|
||||
/// skipped".
|
||||
in property <string> summary;
|
||||
/// "Groups 1–30 of 1,836".
|
||||
in property <string> page-label;
|
||||
in property <bool> has-prev: false;
|
||||
in property <bool> has-next: false;
|
||||
/// A check or a consolidation is running.
|
||||
in property <bool> running: false;
|
||||
in property <float> fraction: 0;
|
||||
in property <string> progress-label;
|
||||
/// The check's button and what it costs.
|
||||
in property <string> check-label;
|
||||
in property <string> check-cost;
|
||||
in property <bool> can-check: false;
|
||||
/// "Move 3,379 copies to trash".
|
||||
in property <string> confirm-label;
|
||||
in property <bool> can-confirm: false;
|
||||
in property <string> error;
|
||||
/// How many groups the library holds, for the entries into this page.
|
||||
in property <int> group-count: 0;
|
||||
|
||||
callback open();
|
||||
callback close();
|
||||
callback check();
|
||||
callback stop();
|
||||
callback confirm();
|
||||
callback prev-page();
|
||||
callback next-page();
|
||||
/// A copy was chosen to stay: row on this page, copy in the group.
|
||||
callback pick-survivor(int, int);
|
||||
/// A group was put in or taken out of the plan, by row on this page.
|
||||
callback toggle(int, bool);
|
||||
}
|
||||
|
||||
component CopyLine inherits Rectangle {
|
||||
in property <DuplicateCopy> copy;
|
||||
in property <bool> enabled: true;
|
||||
callback picked();
|
||||
|
||||
height: Theme.touch-target;
|
||||
border-radius: Theme.radius-sm;
|
||||
background: root.copy.survivor ? Theme.surface-raised : transparent;
|
||||
|
||||
accessible-role: button;
|
||||
accessible-label: (root.copy.survivor ? "Stays: " : "To the trash: ") + root.copy.path;
|
||||
accessible-action-default => { root.picked(); }
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap-sm;
|
||||
padding-right: Theme.gap-sm;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Text {
|
||||
width: 88px;
|
||||
text: root.copy.survivor ? "Stays" : "To trash";
|
||||
color: root.copy.survivor ? Theme.ink : Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: root.copy.survivor ? 600 : 400;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.copy.path;
|
||||
color: root.copy.survivor ? Theme.ink : Theme.ink-dim;
|
||||
font-size: Theme.text-sm;
|
||||
overflow: elide;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
enabled: root.enabled;
|
||||
clicked => { root.picked(); }
|
||||
}
|
||||
}
|
||||
|
||||
component GroupCard inherits Rectangle {
|
||||
in property <DuplicateRow> group;
|
||||
in property <bool> busy;
|
||||
callback pick(int);
|
||||
callback toggled(bool);
|
||||
|
||||
background: Theme.surface;
|
||||
border-radius: Theme.radius;
|
||||
height: layout.preferred-height;
|
||||
|
||||
layout := HorizontalLayout {
|
||||
padding: Theme.gap;
|
||||
spacing: Theme.gap;
|
||||
|
||||
// One picture for the group: the copies are the same file.
|
||||
Rectangle {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
background: Theme.ground;
|
||||
border-radius: Theme.radius-sm;
|
||||
clip: true;
|
||||
|
||||
if root.group.has-thumb: Image {
|
||||
width: parent.width;
|
||||
height: parent.height;
|
||||
source: root.group.thumb;
|
||||
image-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 2px;
|
||||
horizontal-stretch: 1;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Value {
|
||||
text: root.group.title;
|
||||
overflow: elide;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
if root.group.can-include && !root.group.done: Check {
|
||||
label: "Include";
|
||||
checked: root.group.include;
|
||||
controlled: true;
|
||||
toggled(on) => { root.toggled(on); }
|
||||
}
|
||||
}
|
||||
|
||||
for copy[i] in root.group.copies: CopyLine {
|
||||
copy: copy;
|
||||
enabled: !root.busy && !root.group.done && root.group.can-include;
|
||||
picked => { root.pick(i); }
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: root.group.status;
|
||||
warn: root.group.warn;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
if root.group.detail != "": Caption {
|
||||
text: root.group.detail;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component DuplicatesPage inherits Rectangle {
|
||||
background: Theme.ground;
|
||||
|
||||
FocusScope {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
init => { self.focus(); }
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
Rectangle {
|
||||
height: 44px;
|
||||
background: Theme.surface;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap;
|
||||
padding-right: Theme.gap;
|
||||
spacing: Theme.gap;
|
||||
|
||||
Button {
|
||||
text: "‹ Back";
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => { Duplicates.close(); }
|
||||
}
|
||||
|
||||
Value { text: "Duplicate originals"; }
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
if Duplicates.running: Button {
|
||||
text: "Stop";
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => { Duplicates.stop(); }
|
||||
}
|
||||
|
||||
// GESTURE: Check duplicate originals are the same file
|
||||
// where: Duplicate originals
|
||||
// touch: Tap "Check"
|
||||
// pointer: Click "Check"
|
||||
// why: Nothing is moved on the catalog's say-so. The
|
||||
// check reads the first and last megabyte of each
|
||||
// copy and its sidecar, keeps what it read, and
|
||||
// drops any group whose bytes or edits differ.
|
||||
// manual: duplicate-originals
|
||||
if !Duplicates.running && Duplicates.can-check: Button {
|
||||
text: Duplicates.check-label;
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => { Duplicates.check(); }
|
||||
}
|
||||
|
||||
// GESTURE: Move the spare copies to the trash
|
||||
// where: Duplicate originals
|
||||
// touch: Tap "Move N copies to trash"
|
||||
// pointer: Click "Move N copies to trash"
|
||||
// why: The one verb, named with its count, and off until
|
||||
// a check has proved something. Each group is merged
|
||||
// onto the copy that stays and the others go to the
|
||||
// trash together or not at all; the trash view gives
|
||||
// them back.
|
||||
// manual: duplicate-originals
|
||||
if !Duplicates.running: Button {
|
||||
text: Duplicates.confirm-label;
|
||||
primary: true;
|
||||
enabled: Duplicates.can-confirm;
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => { Duplicates.confirm(); }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
y: parent.height - 1px;
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
}
|
||||
|
||||
if Duplicates.running: ProgressBar {
|
||||
fraction: Duplicates.fraction;
|
||||
label: Duplicates.progress-label;
|
||||
}
|
||||
|
||||
if Duplicates.error != "": Rectangle {
|
||||
height: 32px;
|
||||
background: Theme.surface;
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap;
|
||||
padding-right: Theme.gap;
|
||||
Caption { text: Duplicates.error; warn: true; overflow: elide; }
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-height: body.preferred-height;
|
||||
|
||||
body := VerticalLayout {
|
||||
width: 100%;
|
||||
padding: Theme.gap-lg;
|
||||
spacing: Theme.gap;
|
||||
alignment: start;
|
||||
|
||||
Caption {
|
||||
text: Duplicates.summary;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
if Duplicates.running: Caption {
|
||||
text: Duplicates.progress-label;
|
||||
}
|
||||
|
||||
if Duplicates.check-cost != "": Caption {
|
||||
text: Duplicates.check-cost;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
// GESTURE: Choose which copy of a duplicate stays
|
||||
// where: Duplicate originals
|
||||
// touch: Tap the copy's path
|
||||
// pointer: Click the copy's path
|
||||
// why: The rule picks the copy outside a backup folder
|
||||
// that still has the camera's name. The path is the
|
||||
// evidence, so the path is what is tapped to
|
||||
// overrule it.
|
||||
// manual: duplicate-originals
|
||||
//
|
||||
// GESTURE: Leave a duplicate group as it is
|
||||
// where: Duplicate originals
|
||||
// touch: Untick "Include" on the group
|
||||
// pointer: Untick "Include" on the group
|
||||
// why: The plan is every group the check proved; a group
|
||||
// you want to keep in two places is taken out of it
|
||||
// rather than argued with.
|
||||
// manual: duplicate-originals
|
||||
for row[i] in Duplicates.rows: GroupCard {
|
||||
group: row;
|
||||
busy: Duplicates.running;
|
||||
pick(copy) => { Duplicates.pick-survivor(i, copy); }
|
||||
toggled(on) => { Duplicates.toggle(i, on); }
|
||||
}
|
||||
|
||||
if Duplicates.has-prev || Duplicates.has-next: HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
alignment: center;
|
||||
|
||||
Button {
|
||||
text: "‹ Previous";
|
||||
enabled: Duplicates.has-prev;
|
||||
clicked => { Duplicates.prev-page(); }
|
||||
}
|
||||
Caption {
|
||||
text: Duplicates.page-label;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Button {
|
||||
text: "Next ›";
|
||||
enabled: Duplicates.has-next;
|
||||
clicked => { Duplicates.next-page(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,12 @@ export component SettingsPage inherits Rectangle {
|
||||
in property <[string]> face-detector-labels;
|
||||
in property <int> face-detector-selected: 0;
|
||||
callback face-detector-picked(int);
|
||||
/// TRACES: FR-CAT-11a
|
||||
/// How many groups of duplicate originals the catalog holds, and the way
|
||||
/// into their review. Here beside the other whole-library passes, and on
|
||||
/// the sidebar under the trash, where the copies it moves will go.
|
||||
in property <int> duplicate-groups: 0;
|
||||
callback find-duplicates();
|
||||
|
||||
// --- cache ---------------------------------------------------------
|
||||
in-out property <string> original-budget;
|
||||
@@ -500,6 +506,45 @@ export component SettingsPage inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-11a
|
||||
// Duplicate originals: the same file catalogued in
|
||||
// several folders. Opens the review; nothing moves
|
||||
// from here.
|
||||
if root.library-open: Rectangle {
|
||||
height: Theme.gap-sm;
|
||||
}
|
||||
|
||||
if root.library-open: Caption {
|
||||
text: root.duplicate-groups > 0
|
||||
? "Duplicate originals: " + root.duplicate-groups
|
||||
+ (root.duplicate-groups == 1 ? " file is" : " files are")
|
||||
+ " in the library more than once. The review "
|
||||
+ "checks each group is the same file, keeps one "
|
||||
+ "copy with every rating, keyword and collection "
|
||||
+ "merged onto it, and moves the rest to the trash."
|
||||
: "Duplicate originals: none. The same file in two "
|
||||
+ "folders would be listed here.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
// GESTURE: Review duplicate originals
|
||||
// where: Settings
|
||||
// touch: Tap "Review duplicate originals"
|
||||
// pointer: Click "Review duplicate originals"
|
||||
// why: Beside the other whole-library passes, because
|
||||
// it is one; the sidebar offers the same page
|
||||
// under the trash.
|
||||
// manual: duplicate-originals
|
||||
if root.library-open && root.duplicate-groups > 0: Rectangle {
|
||||
height: Theme.control-height;
|
||||
|
||||
Button {
|
||||
x: 0;
|
||||
text: "Review duplicate originals";
|
||||
clicked => { root.find-duplicates(); }
|
||||
}
|
||||
}
|
||||
|
||||
if root.activity-kept > 0: Rectangle {
|
||||
height: Theme.control-height;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user