wip: ingest

This commit is contained in:
2026-08-22 15:34:43 +02:00
parent 735683b849
commit 1526c957cf
9 changed files with 1570 additions and 26 deletions
+361
View File
@@ -0,0 +1,361 @@
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 root. Shown, not editable — see the header comment.
in property <string> destination;
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.destination;
overflow: elide;
horizontal-stretch: 1;
}
}
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 after every file is verified in the library — and, on a library that syncs, after it has reached the server.";
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; }
}
}
}
}
}