Export took a path typed into the settings page, or a folder inside the library on the server. The first is how exports end up somewhere nobody looks; the second put JPEGs into the tree a scan catalogues, where they came back as photographs beside the RAWs they were made from. The destination is now an album (FR-EXP-10), chosen by name in the export sheet. Albums are listed under the collections in the sidebar; "+" there, or "New album…" in the sheet, opens a sheet for its name and its folder — on this device through the platform's dialogue, or on the server through the browser with "New folder". A server folder inside the library is refused, and the sheet says why. Selecting an album narrows the grid to the photographs behind its files: library::Scope is Collection or Album, and scope_clause is the one place the two are spelled, which also retires the two copies of the collection predicate total_images_scoped and read_cells_scoped had inlined. A batch resolves the album when it starts, and refuses in words when none is chosen, it has gone, or its folder is local to another device. Each item reports the image it came from, and the files written are recorded against the album in one transaction when the batch ends. A server album lives outside the library, so its queued uploads are relative to the account root. That is a third line in the outbox's .dest record rather than a leading slash, because a record written before albums may carry a stray slash and must keep the meaning it was written with. An export folder set before albums becomes an album called "Exports" on first open, so upgrading does not lose where exports were going. The old destination fields stay in ExportSettings so older settings files still read.
343 lines
14 KiB
Plaintext
343 lines
14 KiB
Plaintext
// TRACES: FR-EXP-7 | FR-UI-5
|
|
// The export defaults, drawn wherever an export is chosen.
|
|
//
|
|
// Two places show them: the settings page, among everything else, and the
|
|
// export sheet Ctrl+E opens, alone with an Export button beneath. They are
|
|
// one set of values — every export runs on the defaults, there is no
|
|
// per-export copy — so the state is a global Rust writes once, and the two
|
|
// panels below read it, rather than forty properties threaded separately
|
|
// through each surface and bound to agree.
|
|
import { Theme } from "theme.slint";
|
|
import { Button, Label, Value, Caption } from "widgets.slint";
|
|
import { Segmented, TextRow, Check, SliderRow } from "controls.slint";
|
|
|
|
export global ExportOptions {
|
|
// --- the image ---------------------------------------------------
|
|
in property <[string]> format-labels;
|
|
in property <int> format-selected: 0;
|
|
in property <int> quality: 90;
|
|
in property <bool> quality-enabled: true;
|
|
in property <[string]> colour-labels;
|
|
in property <int> colour-selected: 0;
|
|
in property <[string]> sizing-labels;
|
|
in property <int> sizing-selected: 0;
|
|
in-out property <int> sizing-value: 0;
|
|
in property <bool> sizing-has-value: false;
|
|
in property <string> sizing-unit: "px";
|
|
/// "Size value" for the modes that take one number, "Width" for the box
|
|
/// modes, where a second field sits below it.
|
|
in property <string> sizing-value-label: "Size value";
|
|
in-out property <int> sizing-height: 0;
|
|
in property <bool> sizing-has-height: false;
|
|
/// The panels offered as buttons, and which one the numbers match — `-1`
|
|
/// where they match none, so a preset never stays lit over fields that
|
|
/// have since been typed over.
|
|
in property <[string]> screen-labels;
|
|
in property <int> screen-selected: -1;
|
|
in property <bool> allow-upscaling: false;
|
|
in property <[string]> sharpening-labels;
|
|
in property <int> sharpening-selected: 0;
|
|
|
|
// --- the file ----------------------------------------------------
|
|
in-out property <string> filename-template;
|
|
in property <[string]> collision-labels;
|
|
in property <int> collision-selected: 0;
|
|
in property <bool> strip-location: true;
|
|
// --- where it goes (FR-EXP-10) -----------------------------------
|
|
//
|
|
// An album, by name. The folder behind it — on this device or on the
|
|
// server — is chosen once, when the album is made, in the platform's
|
|
// dialogue or the server browser; exporting only says which album.
|
|
in property <[string]> album-labels;
|
|
/// -1 while none is chosen, and an export is refused until one is.
|
|
in property <int> album-selected: -1;
|
|
/// Where the chosen album's files go, in words.
|
|
in property <string> album-detail;
|
|
|
|
callback format-picked(int);
|
|
callback quality-changed(int);
|
|
callback colour-picked(int);
|
|
callback sizing-picked(int);
|
|
callback sizing-value-changed(string);
|
|
callback sizing-height-changed(string);
|
|
callback screen-picked(int);
|
|
callback upscaling-toggled(bool);
|
|
callback sharpening-picked(int);
|
|
callback template-changed(string);
|
|
callback collision-picked(int);
|
|
callback strip-location-toggled(bool);
|
|
callback album-picked(int);
|
|
/// Make a new album, from here rather than from the sidebar.
|
|
callback album-new();
|
|
}
|
|
|
|
// What the image becomes: format, quality, colour, size, sharpening. The
|
|
// body of a panel, not a panel — each surface draws its own frame and
|
|
// heading around it.
|
|
export component ExportImageOptions inherits VerticalLayout {
|
|
spacing: Theme.gap;
|
|
|
|
Segmented {
|
|
label: "Format";
|
|
options: ExportOptions.format-labels;
|
|
selected: ExportOptions.format-selected;
|
|
picked(i) => { ExportOptions.format-picked(i); }
|
|
}
|
|
|
|
// A bounded number, so it gets the control for one.
|
|
//
|
|
// This was a free-text field: the range lived in the
|
|
// hint and was enforced nowhere, and `to-float()`
|
|
// answers 0 for anything unparseable — so a typo saved
|
|
// a quality of 0 and the page then showed the 0 back as
|
|
// though it had been asked for. The track carries the
|
|
// range and the box refuses what it cannot read.
|
|
SliderRow {
|
|
label: "Quality";
|
|
// Says why it is greyed rather than leaving the
|
|
// user to work out that PNG has no quality.
|
|
hint: ExportOptions.quality-enabled ? "1 to 100"
|
|
: "the chosen format is lossless";
|
|
value: ExportOptions.quality;
|
|
// No meaningful neutral: quality has a sensible
|
|
// default but not a *zero*, and a default marker
|
|
// partway along a track reads as one.
|
|
default-value: 1;
|
|
minimum: 1;
|
|
maximum: 100;
|
|
enabled: ExportOptions.quality-enabled;
|
|
changed(v) => { ExportOptions.quality-changed(v); }
|
|
}
|
|
|
|
Segmented {
|
|
label: "Colour space";
|
|
hint: "profile embedded on export";
|
|
options: ExportOptions.colour-labels;
|
|
selected: ExportOptions.colour-selected;
|
|
picked(i) => { ExportOptions.colour-picked(i); }
|
|
}
|
|
|
|
Segmented {
|
|
label: "Size";
|
|
options: ExportOptions.sizing-labels;
|
|
selected: ExportOptions.sizing-selected;
|
|
picked(i) => { ExportOptions.sizing-picked(i); }
|
|
}
|
|
|
|
// Only where the chosen mode carries a number:
|
|
// "Original" has none, and a field showing 0 beside it
|
|
// would invite the reading "zero pixels".
|
|
if ExportOptions.sizing-has-value: TextRow {
|
|
label: ExportOptions.sizing-value-label;
|
|
text: ExportOptions.sizing-value;
|
|
unit: ExportOptions.sizing-unit;
|
|
field-width: 90px;
|
|
accepted(t) => { ExportOptions.sizing-value-changed(t); }
|
|
}
|
|
|
|
// TRACES: FR-EXP-3
|
|
// The second axis, for the box modes only.
|
|
if ExportOptions.sizing-has-height: TextRow {
|
|
label: "Height";
|
|
text: ExportOptions.sizing-height;
|
|
unit: "px high";
|
|
field-width: 90px;
|
|
accepted(t) => { ExportOptions.sizing-height-changed(t); }
|
|
}
|
|
|
|
// TRACES: FR-EXP-3
|
|
// Panel sizes as buttons, because the numbers are the
|
|
// whole difficulty: a television's art mode accepts one
|
|
// resolution and rejects everything else, and getting
|
|
// it by typing four digits twice is a step at which a
|
|
// photographer discovers they were wrong only after the
|
|
// upload.
|
|
//
|
|
// Alongside the fields rather than instead of them —
|
|
// the presets are a shortcut to a pair of numbers, not
|
|
// a replacement for being able to say any pair.
|
|
if ExportOptions.sizing-has-height: Segmented {
|
|
label: "Screen";
|
|
hint: "fills in both numbers";
|
|
options: ExportOptions.screen-labels;
|
|
selected: ExportOptions.screen-selected;
|
|
picked(i) => { ExportOptions.screen-picked(i); }
|
|
}
|
|
|
|
Check {
|
|
label: "Allow upscaling";
|
|
hint: "Off, a request larger than the source exports "
|
|
+ "at source size rather than failing.";
|
|
checked: ExportOptions.allow-upscaling;
|
|
toggled(on) => { ExportOptions.upscaling-toggled(on); }
|
|
}
|
|
|
|
Segmented {
|
|
label: "Output sharpening";
|
|
hint: "scaled by the resize factor";
|
|
options: ExportOptions.sharpening-labels;
|
|
selected: ExportOptions.sharpening-selected;
|
|
picked(i) => { ExportOptions.sharpening-picked(i); }
|
|
}
|
|
}
|
|
|
|
// What the file is called, where it lands, and what leaves with it.
|
|
export component ExportFileOptions inherits VerticalLayout {
|
|
spacing: Theme.gap;
|
|
|
|
TextRow {
|
|
label: "Filename template";
|
|
hint: "{name} {seq} {date} {dimensions} {preset}";
|
|
text <=> ExportOptions.filename-template;
|
|
field-width: 260px;
|
|
placeholder: "{name}";
|
|
accepted(t) => { ExportOptions.template-changed(t); }
|
|
}
|
|
|
|
Segmented {
|
|
label: "If the file exists";
|
|
options: ExportOptions.collision-labels;
|
|
selected: ExportOptions.collision-selected;
|
|
picked(i) => { ExportOptions.collision-picked(i); }
|
|
}
|
|
|
|
// TRACES: FR-EXP-10
|
|
// Where the file lands, before what it is called: an album, which
|
|
// names a folder on this device or on the server and remembers
|
|
// what was sent to it. Not the library — a JPEG written into the
|
|
// library's own tree would be catalogued by the next scan as a
|
|
// photograph of its own.
|
|
if ExportOptions.album-labels.length > 0: Segmented {
|
|
label: "Album";
|
|
options: ExportOptions.album-labels;
|
|
selected: ExportOptions.album-selected;
|
|
columns: 3;
|
|
picked(i) => { ExportOptions.album-picked(i); }
|
|
}
|
|
|
|
if ExportOptions.album-labels.length == 0: Caption {
|
|
text: "No album yet. An album is a folder exports go to — on this device or on the server — and the photographs sent to it are listed under Albums in the library.";
|
|
wrap: word-wrap;
|
|
}
|
|
|
|
if ExportOptions.album-detail != "": Caption {
|
|
text: ExportOptions.album-detail;
|
|
wrap: word-wrap;
|
|
}
|
|
|
|
HorizontalLayout {
|
|
alignment: start;
|
|
Button {
|
|
text: "New album…";
|
|
clicked => { ExportOptions.album-new(); }
|
|
}
|
|
}
|
|
|
|
Check {
|
|
label: "Strip location and personal metadata";
|
|
hint: "On. An export is usually the copy that leaves "
|
|
+ "this machine, and a location embedded in a "
|
|
+ "published photograph cannot be recalled.";
|
|
checked: ExportOptions.strip-location;
|
|
toggled(on) => { ExportOptions.strip-location-toggled(on); }
|
|
}
|
|
}
|
|
|
|
// TRACES: FR-EXP-7 | FR-UI-5
|
|
// The export defaults on their own, over whatever is open, with the button
|
|
// that uses them — what Ctrl+E opens, and the nearest thing this application
|
|
// has to another editor's export dialogue.
|
|
//
|
|
// Every change here is saved as it is made, exactly as on the settings page,
|
|
// because they are the same values: closing without exporting keeps them, and
|
|
// the next Ctrl+Shift+E runs on what was chosen here.
|
|
export component ExportSheet inherits Rectangle {
|
|
/// What pressing Export would do — "Export JPEG", "Export 40 to the
|
|
/// library" — from whichever view opened the sheet.
|
|
in property <string> action-label: "Export";
|
|
/// False when there is nothing to export, or an export is already running.
|
|
in property <bool> can-export: true;
|
|
/// A save that failed, as the settings page reports it.
|
|
in property <string> error;
|
|
|
|
callback export();
|
|
callback dismiss();
|
|
|
|
background: #000000CC;
|
|
|
|
// Swallows the taps that miss the card, and closes. First, so the card's
|
|
// own controls sit above it.
|
|
TouchArea {
|
|
clicked => { root.dismiss(); }
|
|
}
|
|
|
|
Rectangle {
|
|
// The settings column's width, so the panels lay out as they do there.
|
|
width: min(680px, parent.width - 2 * Theme.gap-lg);
|
|
height: min(body.preferred-height + footer.preferred-height
|
|
+ 3 * Theme.gap-lg,
|
|
parent.height - 2 * Theme.gap-lg);
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
background: Theme.surface;
|
|
border-radius: Theme.radius;
|
|
border-width: 1px;
|
|
border-color: Theme.rule;
|
|
|
|
// Stops a press on the card reaching the scrim behind it.
|
|
TouchArea { }
|
|
|
|
VerticalLayout {
|
|
padding: Theme.gap-lg;
|
|
spacing: Theme.gap-lg;
|
|
|
|
// The options scroll and the buttons do not: on a short window the
|
|
// form is longer than the card, and an Export button scrolled out
|
|
// of sight is a sheet with no way to do what it is for.
|
|
Flickable {
|
|
vertical-stretch: 1;
|
|
viewport-height: body.preferred-height;
|
|
|
|
body := VerticalLayout {
|
|
width: 100%;
|
|
spacing: Theme.gap;
|
|
alignment: start;
|
|
|
|
Text {
|
|
text: "Export";
|
|
color: Theme.ink;
|
|
font-size: Theme.text-lg;
|
|
font-weight: 600;
|
|
}
|
|
|
|
if root.error != "": Caption {
|
|
text: root.error;
|
|
warn: true;
|
|
wrap: word-wrap;
|
|
}
|
|
|
|
ExportImageOptions {}
|
|
ExportFileOptions {}
|
|
}
|
|
}
|
|
|
|
footer := HorizontalLayout {
|
|
spacing: Theme.gap-sm;
|
|
alignment: end;
|
|
|
|
Button {
|
|
text: "Close";
|
|
clicked => { root.dismiss(); }
|
|
}
|
|
|
|
Button {
|
|
text: root.action-label;
|
|
primary: true;
|
|
enabled: root.can-export;
|
|
clicked => { root.export(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|