Files
DarkRoom/ui/dr-ui/ui/import.slint
T
dtourolleandClaude Opus 5 d04087af83 Import into the library, which is on the server
There was a local destination, defaulting to ~/Pictures, and an "upload" switch
that could be turned off. That was wrong twice over. DarkRoom's library *is* a
folder on a Nextcloud server (FR-NC-6) — there is no local library — so a
user-chosen local destination built a second pile of photographs that no view
in the application ever lists, and made "where did my import go" a question
with two answers.

An import now has exactly one destination and the page asks nothing about it.
With no account there is nowhere to go at all, so Import is refused rather than
quietly filling a folder.

What lands on the device is a staging copy in a directory the app owns, the
same shape `export` uses for its outbox and for the reason its module docs
give: staging first is the only path, not a fallback for being offline. The
bytes have to reach disk before the network — streaming a card straight to the
server would let a move-import erase a card against an in-flight upload, and
would make importing impossible with no connection (FR-NC-10). A staged file is
removed once the server confirms it; one that is not confirmed stays queued, and
the next import drains it.

And the rule that was stated but never enforced: `retirable` was reported and
`dr_ingest::retire` was never called by anything, so a move-import silently
behaved as a copy. The card is now emptied by the worker, of exactly those
photographs the *server* has confirmed — not those merely written here, because
the staging copy is removed moments later and anything unconfirmed would then
exist nowhere at all.

FR-NC-7b said "copied locally first ... then queued for upload", which is a
staging area; it has been rewritten to say so in terms that do not also permit
what was built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:09:26 +02:00

381 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar } from "widgets.slint";
import { Segmented, TextRow, Check, ChoiceChip } from "controls.slint";
// TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a
// Import: bringing a card into the library.
//
// A full-window page, the same shape as Settings and for the first of the same
// reasons — it refers to the library constantly, and a modal that has to be
// dismissed to check where something went is a dialogue arguing with itself.
//
// The second reason does not apply and produces the one structural difference:
// settings save on change and have nothing to confirm, where an import is a
// decision. So this page has a commit button, and everything above it is the
// sentence that button completes: take *these* files, from *there*, and put
// them *here*.
//
// # Why the destination is shown and not editable
//
// A photograph goes into the library, and the library is chosen at launch.
// Offering a second place to set it here would let the two disagree, and the
// disagreement would be discovered as a folder of photographs the grid does
// not show. What *is* editable is the layout within it (FR-NC-7a), which is a
// different question with a visible answer right beside it.
// One volume the app found, as a row that can be picked.
//
// The subtitle carries why it is being offered — a camera card, a removable
// drive — because "EOS DIGITAL" and "archive" look equally plausible in a list
// and only one of them is what the user just plugged in.
component VolumeRow inherits Rectangle {
in property <string> label;
in property <string> detail;
in property <bool> selected: false;
callback clicked();
height: 44px;
border-radius: Theme.radius-sm;
background: root.selected ? Theme.selected
: (touch.pressed ? Theme.pressed
: (touch.has-hover ? Theme.hover : transparent));
border-width: root.selected ? 1px : 0px;
border-color: Theme.selected-ring;
touch := TouchArea {
clicked => { root.clicked(); }
}
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap;
VerticalLayout {
alignment: center;
spacing: 1px;
Label { text: root.label; body: true; }
Caption { text: root.detail; }
}
Rectangle { horizontal-stretch: 1; }
}
}
export component ImportPage inherits Rectangle {
// --- where the photographs are coming from ---------------------------
in property <[string]> volume-labels;
in property <[string]> volume-details;
/// -1 while nothing is chosen, which is what the page opens on.
in property <int> volume-selected: -1;
/// The chosen source, also typable — a card mounted somewhere this app
/// does not think to look is still a card.
in-out property <string> card-path;
/// Whether the source holds a DCIM folder. Advisory: plenty of legitimate
/// sources do not, so this informs and never blocks.
in property <bool> card-looks-right: false;
callback volume-picked(int);
callback card-path-changed(string);
callback refresh-volumes();
// --- what is on it ---------------------------------------------------
in property <bool> surveying: false;
/// "1,847 photographs · 61.2 GB". Empty before anything has been counted.
in property <string> survey-summary;
// --- where they are going --------------------------------------------
/// The library folder on the server — the only destination there is.
///
/// Empty means no account is signed in, and Import stays disabled: the
/// library lives on the server, so an import without one has nowhere to
/// go at all.
in property <string> upload-target;
in-out property <string> folder-template;
/// The template expanded against a real date, so the answer to "what will
/// this do" is on screen rather than in the manual.
in property <string> template-preview;
in property <[string]> mode-labels;
in property <int> mode-selected: 0;
/// Whether the chosen mode will eventually erase the card.
in property <bool> mode-is-move: false;
in property <[string]> duplicate-labels;
in property <int> duplicate-selected: 0;
in property <bool> verify: true;
in property <bool> backup-on: false;
in-out property <string> backup-path;
callback template-changed(string);
callback mode-picked(int);
callback duplicate-picked(int);
callback verify-toggled(bool);
callback backup-toggled(bool);
callback backup-path-changed(string);
// --- doing it --------------------------------------------------------
in property <bool> running: false;
in property <float> fraction: 0;
/// What it is doing now, or what it did — one line, always present once
/// something has happened.
in property <string> status;
in property <string> error;
/// Whether Import can be pressed at all.
in property <bool> can-start: false;
callback begin();
callback stop();
callback close();
background: Theme.ground;
// Somewhere for a key to start from, so Android's back gesture closes the
// page rather than the application. Same reasoning as the settings page.
FocusScope {
width: 0px;
height: 0px;
init => { self.focus(); }
}
VerticalLayout {
// --- header ------------------------------------------------------
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;
// Leaving mid-import would abandon a worker with no way
// back to it: the page is the only thing that reports what
// it is doing.
enabled: !root.running;
clicked => { root.close(); }
}
Value { text: "Import"; }
Rectangle { horizontal-stretch: 1; }
if root.running: Button {
text: "Stop";
y: (parent.height - self.height) / 2;
clicked => { root.stop(); }
}
if !root.running: Button {
text: "Import";
y: (parent.height - self.height) / 2;
enabled: root.can-start;
clicked => { root.begin(); }
}
}
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rule;
}
}
// The bar sits directly under the header while a run is going, which
// is where every other progress bar in this application is.
if root.running: ProgressBar {
fraction: root.fraction;
indeterminate: root.surveying;
}
if root.error != "": Rectangle {
height: 32px;
background: Theme.surface;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
Caption { text: root.error; warn: true; overflow: elide; }
}
}
// Named child plus an explicit viewport height, as the settings page
// does it: a Flickable cannot measure an anonymous layout, and scrolls
// nowhere without being told how tall its contents are.
Flickable {
vertical-stretch: 1;
viewport-height: content.preferred-height;
content := VerticalLayout {
width: 100%;
padding: Theme.gap;
spacing: Theme.gap;
alignment: start;
// --- source ------------------------------------------------
Panel {
PanelHeading { text: "From"; }
if root.volume-labels.length == 0: Caption {
text: "No removable volume found. Type where the card is mounted, or plug it in and refresh.";
wrap: word-wrap;
}
for label[i] in root.volume-labels: VolumeRow {
label: label;
detail: root.volume-details[i];
selected: i == root.volume-selected;
clicked => { root.volume-picked(i); }
}
TextRow {
label: "Location";
text <=> root.card-path;
placeholder: "/run/media/…";
field-width: 320px;
enabled: !root.running;
accepted(t) => { root.card-path-changed(t); }
}
HorizontalLayout {
spacing: Theme.gap;
Button {
text: "Refresh";
enabled: !root.running;
clicked => { root.refresh-volumes(); }
}
// Says what was found the moment it is known, so the
// user is not asked to press Import on faith.
Caption {
text: root.surveying ? "Looking…"
: (root.survey-summary != "" ? root.survey-summary : "");
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; }
}
if root.card-path != "" && !root.card-looks-right && !root.surveying: Caption {
// Advisory, not a block: an archive folder is a
// legitimate source and has no DCIM either.
text: "No DCIM folder here — check this is the card and not somewhere else.";
wrap: word-wrap;
}
}
// --- destination -------------------------------------------
Panel {
PanelHeading { text: "Into"; }
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Library"; }
Value {
text: root.upload-target != "" ? root.upload-target
: "not signed in";
overflow: elide;
horizontal-stretch: 1;
}
}
// The order is the promise, and a user watching a slow
// upload needs to already know the photographs are safe.
// Said here, beside the destination, rather than beside the
// Import button where it would read as a warning.
if root.upload-target != "": Caption {
text: "Copied to this computer and verified first, then uploaded. Anything that does not go up stays queued for the next import.";
wrap: word-wrap;
}
if root.upload-target == "": Caption {
text: "Sign in first — the library this imports into is on the server.";
wrap: word-wrap;
}
TextRow {
label: "Folders";
text <=> root.folder-template;
placeholder: "{yyyy}/{yyyy}-{mm}-{dd}";
field-width: 320px;
hint: "Tokens: {yyyy} {yy} {mm} {dd} {date} {make} {model}";
enabled: !root.running;
accepted(t) => { root.template-changed(t); }
}
// The template's answer, against a real photograph's date.
// A template language nobody can predict the output of is
// a template language nobody will change.
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Example"; }
Value { text: root.template-preview; }
Rectangle { horizontal-stretch: 1; }
}
}
// --- how ----------------------------------------------------
Panel {
PanelHeading { text: "How"; }
Segmented {
label: "Transfer";
options: root.mode-labels;
selected: root.mode-selected;
enabled: !root.running;
picked(i) => { root.mode-picked(i); }
}
if root.mode-is-move: Caption {
// The one operation in this application with no undo,
// and the only honest place to say so is beside the
// control that arms it (FR-NC-7b).
text: "The card is emptied only of photographs the server has confirmed. Anything that does not upload keeps its card copy — which is then the only copy there is.";
wrap: word-wrap;
}
Segmented {
label: "Already in the library";
options: root.duplicate-labels;
selected: root.duplicate-selected;
enabled: !root.running;
picked(i) => { root.duplicate-picked(i); }
}
Check {
label: "Verify every file";
hint: "Reads each copy back and compares it. Catches a dying card, which is otherwise invisible until the photograph is opened.";
checked: root.verify;
// `Check` has no enabled property, so the guard is on
// the callback: a toggle mid-run would change what the
// running worker was told to do, which it cannot hear.
toggled(v) => { if (!root.running) { root.verify-toggled(v); } }
}
Check {
label: "Second copy";
hint: "Written from the same read as the first, so it is a check on the copy rather than a copy of it.";
checked: root.backup-on;
toggled(v) => { if (!root.running) { root.backup-toggled(v); } }
}
if root.backup-on: TextRow {
label: "Second copy in";
text <=> root.backup-path;
placeholder: "/mnt/backup/Photos";
field-width: 320px;
enabled: !root.running;
accepted(t) => { root.backup-path-changed(t); }
}
}
// --- what happened -----------------------------------------
if root.status != "": Panel {
PanelHeading { text: root.running ? "Working" : "Result"; }
Caption { text: root.status; wrap: word-wrap; }
}
}
}
}
}