mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge main into fix/issue-23-local-folder-async
# Conflicts: # crates/ui-egui/src/panels/grid.rs
This commit is contained in:
@@ -31,6 +31,7 @@ and often so a crash loses minutes, not hours.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
|
||||
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
|
||||
|
||||
@@ -31,6 +31,7 @@ and often so a crash loses minutes, not hours.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
|
||||
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,12 +1,16 @@
|
||||
LightCraft
|
||||
Copyright the LightCraft contributors. Licensed under MIT OR Apache-2.0.
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors.
|
||||
Licensed under the MIT License or the Apache License, Version 2.0, at your option
|
||||
(LICENSE-MIT, LICENSE-APACHE).
|
||||
|
||||
This product bundles third-party material under the following licences:
|
||||
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
|
||||
not open source and are not covered by the license above; their terms are in
|
||||
docs/brand/LICENSE-brand.txt.
|
||||
|
||||
- Source Sans 3 (assets/fonts/SourceSans3-*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt.
|
||||
- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Third-party material
|
||||
|
||||
Every bundled asset (fonts, icons, images) and its licence is listed in assets/ATTRIBUTION.md.
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in assets/ATTRIBUTION.md.
|
||||
|
||||
Algorithms are implemented from public specifications and papers (see crate READMEs). Rust dependencies
|
||||
(egui, eframe, image, rayon, serde, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
|
||||
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md.
|
||||
|
||||
@@ -396,12 +396,21 @@ And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video stud
|
||||
|
||||
## License and credits
|
||||
|
||||
LightCraft is licensed under **MIT OR Apache-2.0**.
|
||||
LightCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. Required notices are in [NOTICE](NOTICE).
|
||||
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
|
||||
|
||||
Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel Adams, *The Tetons and the Snake River*
|
||||
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
|
||||
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
|
||||
Inter (SIL OFL). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
|
||||
Inter (SIL OFL 1.1). All icons are original.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
|
||||
|
||||
Notes
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
|
||||
@@ -61,7 +61,7 @@ fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
|
||||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
|
||||
let (ids, template, start) = rename_args(s, p, "photo.renamePreview")?;
|
||||
Ok(serde_json::to_value(s.plan_rename(&ids, &template, start)).unwrap_or_default())
|
||||
}),
|
||||
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rename Photos",
|
||||
[],
|
||||
None,
|
||||
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
|
||||
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let (ids, template, start) = rename_args(s, p, "photo.rename")?;
|
||||
|
||||
@@ -104,7 +104,12 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let (mut built, mut removed, mut failed) = (0usize, 0usize, Vec::new());
|
||||
if !discard {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
|
||||
// a chosen folder on another drive must be there: never recreate it on this one
|
||||
if s.smart_previews_dir.is_some() {
|
||||
crate::smart::check_writable(&dir).map_err(|e| bad(C, e))?;
|
||||
} else {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
}
|
||||
for id in ids {
|
||||
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
|
||||
@@ -134,6 +139,69 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"built": built, "removed": removed, "failed": failed}))
|
||||
}
|
||||
|
||||
/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be
|
||||
/// used now (a chosen folder on an unplugged drive is `available: false`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn location_json(s: &Session) -> Value {
|
||||
let dir = s.media.smart_dir.clone();
|
||||
let default = s.library.as_ref().filter(|l| l.on_disk).map(|l| crate::smart::dir(&l.dir));
|
||||
let (count, bytes) = dir.as_deref().map_or((0, 0), crate::smart::stats);
|
||||
json!({
|
||||
"path": dir.as_ref().map(|d| d.to_string_lossy()),
|
||||
"default": default.map(|d| d.to_string_lossy().to_string()),
|
||||
"custom": s.smart_previews_dir.is_some(),
|
||||
"available": dir.as_ref().is_some_and(|d| d.is_dir()) || s.smart_previews_dir.is_none(),
|
||||
"count": count,
|
||||
"bytes": bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Show or change where smart previews are kept (per library).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn smart_location(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.smartPreviewsLocation";
|
||||
let current = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
|
||||
let reset = p.get("reset").and_then(Value::as_bool).unwrap_or(false);
|
||||
let chosen = str_param(p, "path").filter(|x| !x.trim().is_empty());
|
||||
if !reset && chosen.is_none() {
|
||||
return Ok(location_json(s));
|
||||
}
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()).ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
|
||||
let default = crate::smart::dir(&lib_dir);
|
||||
let (new_dir, custom) = match chosen {
|
||||
Some(x) if !reset => {
|
||||
let d = std::path::PathBuf::from(x.trim());
|
||||
if !d.is_absolute() {
|
||||
return Err(bad(C, "path must be an absolute folder path"));
|
||||
}
|
||||
(d.clone(), (d != default).then_some(d))
|
||||
}
|
||||
_ => (default, None),
|
||||
};
|
||||
if new_dir == current {
|
||||
return Ok(location_json(s));
|
||||
}
|
||||
// the new place must work before anything moves; there is no fallback to another drive
|
||||
crate::smart::check_writable(&new_dir).map_err(|e| bad(C, e))?;
|
||||
let (held, _) = crate::smart::stats(¤t);
|
||||
let existing = match str_param(p, "existing") {
|
||||
Some(x) => Some(crate::smart::Existing::parse(x).ok_or_else(|| bad(C, "existing must be move, leave or discard"))?),
|
||||
None if held == 0 => Some(crate::smart::Existing::Leave),
|
||||
None => None,
|
||||
};
|
||||
let Some(existing) = existing else {
|
||||
return Err(bad(C, format!("{held} smart preview(s) are in {}: pass existing = move, leave or discard", current.display())));
|
||||
};
|
||||
let (moved, failed) = crate::smart::migrate(¤t, &new_dir, existing);
|
||||
s.smart_previews_dir = custom;
|
||||
s.media.smart_dir = Some(new_dir);
|
||||
s.save_prefs()?;
|
||||
let mut r = location_json(s);
|
||||
r["handled"] = json!(moved);
|
||||
r["failed"] = json!(failed);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Whether photo `id` has a smart preview.
|
||||
pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool {
|
||||
match (&s.media.smart_dir, s.catalog.photo(id)) {
|
||||
@@ -154,6 +222,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
smart
|
||||
),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cmd!(
|
||||
"library.smartPreviewsLocation",
|
||||
"Smart Previews Location",
|
||||
[],
|
||||
None,
|
||||
"{path?: absolute folder, reset?: bool, existing?: move|leave|discard} — without path/reset: where this library keeps its smart previews → {path, default, custom, available, count, bytes}. With path (or reset = back to `Smart Previews` in the library): use that folder instead (must exist or have an existing parent, and accept writes; never falls back to another drive). Smart previews already in the old folder need `existing`: move them, leave them (build again), or discard them → also {handled, failed}",
|
||||
always,
|
||||
smart_location
|
||||
),
|
||||
cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| {
|
||||
let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
|
||||
let online = match s.catalog.photo(id).map(|p| p.source.clone()) {
|
||||
|
||||
+47
-32
@@ -434,8 +434,11 @@ pub struct ExportOptions {
|
||||
pub limit_kb: Option<u32>,
|
||||
pub sharpen: SharpenFor,
|
||||
pub sharpen_amount: SharpenAmount,
|
||||
/// File name template: `{name}` (original stem), `{seq}` (zero-padded to 3, counting from
|
||||
/// `start_number`), `{date}` (capture date, YYYYMMDD); the extension is appended.
|
||||
/// File name template with the batch-rename tokens ([`crate::rename::expand_tokens`]): `{name}`
|
||||
/// (original stem), `{seq}` (zero-padded to 3, counting from `start_number`; `{seq:N}` for N
|
||||
/// digits), `{date}` (capture date, YYYYMMDD; `{date:%Y-%m-%d}`…), `{num}`, `{folder}`,
|
||||
/// `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; the output
|
||||
/// extension is appended.
|
||||
pub naming: String,
|
||||
/// First `{seq}` value.
|
||||
pub start_number: u32,
|
||||
@@ -618,36 +621,23 @@ impl ExportOptions {
|
||||
if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space }
|
||||
}
|
||||
|
||||
/// Output file name for photo `stem` at 1-based position `seq` in a batch.
|
||||
pub fn file_name(&self, stem: &str, seq: usize) -> String {
|
||||
self.file_name_dated(stem, seq, None)
|
||||
}
|
||||
|
||||
/// [`ExportOptions::file_name`] with the capture time (ISO 8601) for `{date}`.
|
||||
pub fn file_name_dated(&self, stem: &str, seq: usize, captured: Option<&str>) -> String {
|
||||
/// Output file name for photo `p` at 1-based position `seq` in a batch (the original's
|
||||
/// extension is kept for [`ExportFormat::Original`]).
|
||||
pub fn file_name_for(&self, p: &lightcraft_catalog::Photo, seq: usize) -> String {
|
||||
let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() };
|
||||
let n = seq + self.start_number.max(1) as usize - 1;
|
||||
let date: String = captured.map(|c| c.chars().take(10).filter(char::is_ascii_digit).collect()).unwrap_or_default();
|
||||
let name = base.replace("{name}", stem).replace("{seq}", &format!("{n:03}")).replace("{date}", &date);
|
||||
let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
|
||||
format!("{name}.{}", self.format.extension())
|
||||
}
|
||||
|
||||
/// Output file name for photo file `file_name` (the original's extension is kept for
|
||||
/// [`ExportFormat::Original`]).
|
||||
pub fn file_name_for(&self, file_name: &str, seq: usize) -> String {
|
||||
self.file_name_for_dated(file_name, seq, None)
|
||||
}
|
||||
|
||||
/// [`ExportOptions::file_name_for`] with the capture time for `{date}`.
|
||||
pub fn file_name_for_dated(&self, file_name: &str, seq: usize, captured: Option<&str>) -> String {
|
||||
let (stem, ext) = file_name.rsplit_once('.').unwrap_or((file_name, ""));
|
||||
let name = self.file_name_dated(stem, seq, captured);
|
||||
let name = crate::rename::expand_tokens(base, p, n, 3);
|
||||
let mut name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
|
||||
let (stem, ext) = p.file_name.rsplit_once('.').unwrap_or((&p.file_name, ""));
|
||||
if name.trim().is_empty() {
|
||||
// every token came out empty (no title, no capture time…): keep the original name
|
||||
name = stem.to_string();
|
||||
}
|
||||
if self.format == ExportFormat::Original {
|
||||
let base = name.trim_end_matches('.');
|
||||
if ext.is_empty() { base.to_string() } else { format!("{base}.{ext}") }
|
||||
} else {
|
||||
name
|
||||
format!("{name}.{}", self.format.extension())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -962,7 +952,7 @@ pub fn prepare_export(
|
||||
seq: usize,
|
||||
) -> Result<PreparedExport, String> {
|
||||
let p = session.catalog.photo(id).ok_or("no such photo")?;
|
||||
let file_name = o.file_name_for_dated(&p.file_name, seq, p.captured.as_deref());
|
||||
let file_name = o.file_name_for(p, seq);
|
||||
let work = if o.format.is_rendered() {
|
||||
let (w, h) = output_size(p, o);
|
||||
let meta = export_metadata(p, o);
|
||||
@@ -1249,12 +1239,35 @@ mod tests {
|
||||
assert_eq!(o.format, ExportFormat::Jpeg);
|
||||
assert_eq!(o.quality, 100);
|
||||
assert_eq!(o.resize, Some(Resize { dont_enlarge: true, height: 0, ..Resize::long_edge(2048) }));
|
||||
assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg");
|
||||
assert_eq!(o.file_name_for(&named("IMG/1.png"), 7), "IMG_1-007.jpg");
|
||||
assert_eq!(o.ppi, 240);
|
||||
let orig = ExportOptions { format: ExportFormat::Original, naming: "{name}-{seq}".into(), ..Default::default() };
|
||||
assert_eq!(orig.file_name_for("DSC_1.NEF", 2), "DSC_1-002.NEF");
|
||||
assert_eq!(orig.file_name_for("noext", 1), "noext-001");
|
||||
assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for("a.cr2", 1), "a.dng");
|
||||
assert_eq!(orig.file_name_for(&named("DSC_1.NEF"), 2), "DSC_1-002.NEF");
|
||||
assert_eq!(orig.file_name_for(&named("noext"), 1), "noext-001");
|
||||
assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for(&named("a.cr2"), 1), "a.dng");
|
||||
}
|
||||
|
||||
fn named(file_name: &str) -> lightcraft_catalog::Photo {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
Photo::new(PhotoId(1), Source::Demo { scene: 0 }, file_name, "", 1, 1, "2026-01-01T00:00:00")
|
||||
}
|
||||
|
||||
/// Export naming takes the same tokens as Rename Photos and import renaming.
|
||||
#[test]
|
||||
fn naming_uses_the_rename_tokens() {
|
||||
let mut p = named("DSC_0815.NEF");
|
||||
p.captured = Some("2026-09-30T14:05:09".into());
|
||||
p.meta.camera = "Model X/2".into();
|
||||
p.meta.title = "Harbour".into();
|
||||
p.rating = 5;
|
||||
let o = |naming: &str| ExportOptions { naming: naming.into(), start_number: 9, ..Default::default() };
|
||||
assert_eq!(o("{date:%Y-%m-%d}_{title}_{seq:2}").file_name_for(&p, 1), "2026-09-30_Harbour_09.jpg");
|
||||
assert_eq!(o("{camera}-{num}-{rating}").file_name_for(&p, 1), "Model X_2-0815-5.jpg", "path separators become _");
|
||||
assert_eq!(o("{name}-{seq}").file_name_for(&p, 3), "DSC_0815-011.jpg", "a bare {{seq}} keeps its 3 digits");
|
||||
assert_eq!(o("{ext}_{name}").file_name_for(&p, 1), "NEF_DSC_0815.jpg");
|
||||
// nothing left after expanding: the original name
|
||||
p.meta.title.clear();
|
||||
assert_eq!(o("{title}").file_name_for(&p, 1), "DSC_0815.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1282,7 +1295,9 @@ mod tests {
|
||||
let full = ExportOptions::default();
|
||||
assert_eq!(ExportOptions::from_json(&full.to_json()), full);
|
||||
assert!(ExportOptions::has_size_param(&full.to_json()), "full size is explicit");
|
||||
assert_eq!(o.file_name_dated("IMG", 2, Some("2026-09-30T10:00:00")), "20260930-IMG-043.tif");
|
||||
let mut p = named("IMG.png");
|
||||
p.captured = Some("2026-09-30T10:00:00".into());
|
||||
assert_eq!(o.file_name_for(&p, 2), "20260930-IMG-043.tif");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -37,7 +37,7 @@ use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use media::{RenderJob, SourceLevel};
|
||||
use serde_json::Value;
|
||||
pub use view::{Browse, LibrarySource, Selection};
|
||||
pub use view::{Browse, FilterChip, LibrarySource, Selection, filter_chips};
|
||||
pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_gpu as gpu, lightcraft_pipeline as pipeline};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -83,6 +83,8 @@ pub struct Session {
|
||||
/// The grid order for the current source/filter/sort (cached by catalog revision).
|
||||
visible: Vec<PhotoId>,
|
||||
visible_key: Option<(u64, String)>,
|
||||
/// Photos in the current source with no filter on (cached like `visible`).
|
||||
total: Option<((u64, String), usize)>,
|
||||
pub undo: Vec<UndoEntry>,
|
||||
pub redo: Vec<UndoEntry>,
|
||||
pub interaction: Option<Interaction>,
|
||||
@@ -155,6 +157,8 @@ pub struct Session {
|
||||
pub import_defaults: import::ImportDefaults,
|
||||
/// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json).
|
||||
pub cache_mb: u32,
|
||||
/// Where smart previews are kept when not in the library folder (persisted in prefs.json).
|
||||
pub smart_previews_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -174,6 +178,7 @@ impl Session {
|
||||
selection: Selection::default(),
|
||||
visible: Vec::new(),
|
||||
visible_key: None,
|
||||
total: None,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
interaction: None,
|
||||
@@ -212,6 +217,7 @@ impl Session {
|
||||
preview_build: None,
|
||||
import_defaults: import::ImportDefaults::default(),
|
||||
cache_mb: 0,
|
||||
smart_previews_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +518,26 @@ impl Session {
|
||||
&self.visible
|
||||
}
|
||||
|
||||
/// Photos in the current source (folder, album, …) before the filter bar and search narrow
|
||||
/// them; `None` where that is not a separate number (Missing Photos).
|
||||
pub fn source_total(&mut self) -> Option<usize> {
|
||||
if self.source == LibrarySource::Missing {
|
||||
return None;
|
||||
}
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}", self.source, self.browse));
|
||||
if self.total.as_ref().map(|t| &t.0) != Some(&key) {
|
||||
let mut f = self.source.to_filter(&Filter::default(), &self.catalog);
|
||||
if self.source == LibrarySource::Folder {
|
||||
let b = self.browse.clone().unwrap_or_default();
|
||||
f.folder = Some(b.path);
|
||||
f.subfolders = b.subfolders;
|
||||
}
|
||||
let n = self.catalog.query(&f, &self.sort).len();
|
||||
self.total = Some((key, n));
|
||||
}
|
||||
self.total.as_ref().map(|t| t.1)
|
||||
}
|
||||
|
||||
pub fn visible_cloned(&mut self) -> Vec<PhotoId> {
|
||||
self.visible().to_vec()
|
||||
}
|
||||
|
||||
@@ -126,6 +126,8 @@ struct PrefsFile {
|
||||
import: crate::import::ImportDefaults,
|
||||
/// Thumbnail disk cache budget (MB, 0 = default).
|
||||
cache_mb: u32,
|
||||
/// Folder for smart previews (default: `Smart Previews` in the library).
|
||||
smart_previews_dir: Option<String>,
|
||||
}
|
||||
|
||||
fn presets_json(s: &Session) -> String {
|
||||
@@ -200,6 +202,10 @@ impl Session {
|
||||
self.recent_keywords = prefs.recent_keywords;
|
||||
self.import_defaults = prefs.import;
|
||||
self.cache_mb = prefs.cache_mb;
|
||||
self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from);
|
||||
if let Some(d) = &self.smart_previews_dir {
|
||||
self.media.smart_dir = Some(d.clone());
|
||||
}
|
||||
// view state
|
||||
if let Some(v) = read_json::<ViewFile>(files.as_mut(), "view.json") {
|
||||
self.source = v.source;
|
||||
@@ -320,6 +326,7 @@ impl Session {
|
||||
recent_keywords: self.recent_keywords.clone(),
|
||||
import: self.import_defaults.clone(),
|
||||
cache_mb: self.cache_mb,
|
||||
smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
|
||||
+56
-13
@@ -1,10 +1,13 @@
|
||||
//! Batch rename: a file-name template applied to the selected photos, renaming the files on
|
||||
//! disk (with their XMP sidecars) safely.
|
||||
//!
|
||||
//! Template tokens: `{name}` (current name without extension), `{seq}` / `{seq:3}` (sequence number,
|
||||
//! zero-padded to 3 digits), `{date}` / `{date:%Y%m%d}` (capture date; `%Y %y %m %d %H %M %S`),
|
||||
//! `{camera}`, `{title}`, `{ext}` (extension, without the dot). Characters that aren't allowed in
|
||||
//! file names become `-`. The original extension is always kept.
|
||||
//! Template tokens (shared with import renaming and export file naming): `{name}` (current name
|
||||
//! without extension), `{num}` (the number at the end of the name: `IMG_0042` → `0042`), `{seq}` /
|
||||
//! `{seq:3}` (sequence number, zero-padded to 3 digits), `{date}` / `{date:%Y%m%d}` (capture date;
|
||||
//! `%Y %y %m %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`,
|
||||
//! `{rating}`, `{title}`, `{creator}`, `{ext}` (extension, without the dot). Unknown tokens stay as
|
||||
//! typed. Characters that aren't allowed in file names become `-`. The original extension is always
|
||||
//! kept.
|
||||
//!
|
||||
//! Safety:
|
||||
//! - a target that exists on disk, or that another photo in the batch gets, receives a `-1`, `-2`…
|
||||
@@ -81,6 +84,21 @@ fn format_date(iso: &str, fmt: &str) -> String {
|
||||
|
||||
/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension.
|
||||
pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
|
||||
let (stem, ext) = split_ext(&p.file_name);
|
||||
let out = expand_tokens(template, p, seq, 1);
|
||||
let mut stem_out = sanitize(&out);
|
||||
if stem_out.is_empty() {
|
||||
stem_out = sanitize(stem);
|
||||
}
|
||||
if stem_out.is_empty() {
|
||||
stem_out = "photo".into();
|
||||
}
|
||||
if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") }
|
||||
}
|
||||
|
||||
/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}`
|
||||
/// is zero-padded to `seq_width` digits.
|
||||
pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String {
|
||||
let (stem, ext) = split_ext(&p.file_name);
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
@@ -95,13 +113,27 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
|
||||
let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None));
|
||||
let v = match name.trim().to_ascii_lowercase().as_str() {
|
||||
"name" | "filename" => stem.to_string(),
|
||||
"num" => {
|
||||
let digits = stem.chars().rev().take_while(char::is_ascii_digit).count();
|
||||
stem[stem.len() - digits..].to_string()
|
||||
}
|
||||
"seq" | "n" => {
|
||||
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(1).min(9);
|
||||
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9);
|
||||
format!("{seq:0w$}")
|
||||
}
|
||||
"date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")),
|
||||
"folder" => match &p.source {
|
||||
Source::File { path } => {
|
||||
Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default()
|
||||
}
|
||||
Source::Demo { .. } => String::new(),
|
||||
},
|
||||
"camera" => p.meta.camera.clone(),
|
||||
"lens" => p.meta.lens.clone(),
|
||||
"iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(),
|
||||
"rating" => p.rating.to_string(),
|
||||
"title" => p.meta.title.clone(),
|
||||
"creator" => p.meta.creator.clone(),
|
||||
"ext" => ext.to_string(),
|
||||
_ => format!("{{{tok}}}"),
|
||||
};
|
||||
@@ -109,14 +141,7 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
|
||||
rest = &rest[i + j + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
let mut stem_out = sanitize(&out);
|
||||
if stem_out.is_empty() {
|
||||
stem_out = sanitize(stem);
|
||||
}
|
||||
if stem_out.is_empty() {
|
||||
stem_out = "photo".into();
|
||||
}
|
||||
if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") }
|
||||
out
|
||||
}
|
||||
|
||||
/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`).
|
||||
@@ -348,4 +373,22 @@ mod tests {
|
||||
assert_eq!(expand(" ", &p, 1), "IMG_0042.CR2", "empty template keeps the name");
|
||||
assert_eq!(expand("../{name}", &p, 1), "-IMG_0042.CR2", "no path components");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_folder_and_metadata_tokens() {
|
||||
let path = std::path::Path::new("shoots").join("2026-09 Coast").join("DSC_0815.NEF");
|
||||
let mut p = Photo::new(PhotoId(1), Source::File { path: path.to_string_lossy().into() }, "DSC_0815.NEF", "NEF", 1, 1, "2026-01-01T00:00:00");
|
||||
p.meta.lens = "24-70mm F2.8".into();
|
||||
p.meta.iso = Some(400);
|
||||
p.meta.creator = "A. Person".into();
|
||||
p.rating = 4;
|
||||
assert_eq!(expand("{folder}_{num}", &p, 1), "2026-09 Coast_0815.NEF");
|
||||
assert_eq!(expand("{rating}star-{iso}-{lens}-{creator}", &p, 1), "4star-400-24-70mm F2.8-A. Person.NEF");
|
||||
// no number at the end of the name / no folder: empty
|
||||
p.file_name = "beach.NEF".into();
|
||||
p.source = Source::Demo { scene: 0 };
|
||||
assert_eq!(expand("{folder}{name}{num}", &p, 1), "beach.NEF");
|
||||
// a bare {seq} is padded to the caller's width
|
||||
assert_eq!(expand_tokens("{seq}|{seq:2}", &p, 7, 3), "007|07");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,82 @@ pub fn dir(library: &Path) -> PathBuf {
|
||||
library.join("Smart Previews")
|
||||
}
|
||||
|
||||
/// Check that smart previews can be written to `dir`: it must be (or, when its parent exists,
|
||||
/// become) a directory that accepts a new file. Never creates missing parents, so an unplugged
|
||||
/// drive's mount point is not silently recreated on the system drive.
|
||||
pub fn check_writable(dir: &Path) -> Result<(), String> {
|
||||
if !dir.is_dir() {
|
||||
let parent_ok = dir.parent().is_some_and(|p| p.is_dir());
|
||||
if !parent_ok {
|
||||
return Err(format!("{} is not available — reconnect the drive or choose another folder", dir.display()));
|
||||
}
|
||||
std::fs::create_dir(dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
|
||||
}
|
||||
let probe = dir.join(format!(".lightcraft-write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"x").map_err(|e| format!("{} is not writable (is the drive full or read-only?): {e}", dir.display()))?;
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The number of smart previews in `dir` and their total size in bytes.
|
||||
pub fn stats(dir: &Path) -> (usize, u64) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return (0, 0) };
|
||||
rd.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "lcsp"))
|
||||
.fold((0, 0), |(n, b), e| (n + 1, b + e.metadata().map_or(0, |m| m.len())))
|
||||
}
|
||||
|
||||
/// What to do with the smart previews in the old folder when the location changes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Existing {
|
||||
/// Move them to the new folder (copy and delete across drives).
|
||||
Move,
|
||||
/// Leave them where they are (they stop being used; build again in the new folder).
|
||||
Leave,
|
||||
/// Delete them.
|
||||
Discard,
|
||||
}
|
||||
|
||||
impl Existing {
|
||||
pub fn parse(s: &str) -> Option<Existing> {
|
||||
match s {
|
||||
"move" => Some(Existing::Move),
|
||||
"leave" => Some(Existing::Leave),
|
||||
"discard" => Some(Existing::Discard),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `what` to the smart previews in `from` for the new folder `to` → (moved or removed, failed).
|
||||
pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
|
||||
let (mut done, mut failed) = (0, Vec::new());
|
||||
if what == Existing::Leave {
|
||||
return (done, failed);
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(from) else { return (done, failed) };
|
||||
for e in rd.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "lcsp")) {
|
||||
let src = e.path();
|
||||
let r = match what {
|
||||
Existing::Discard => std::fs::remove_file(&src),
|
||||
_ => {
|
||||
let dst = to.join(e.file_name());
|
||||
if dst.exists() {
|
||||
// named by content: the copy already there is the same preview
|
||||
std::fs::remove_file(&src)
|
||||
} else {
|
||||
std::fs::rename(&src, &dst).or_else(|_| std::fs::copy(&src, &dst).and_then(|_| std::fs::remove_file(&src)))
|
||||
}
|
||||
}
|
||||
};
|
||||
match r {
|
||||
Ok(()) => done += 1,
|
||||
Err(err) => failed.push(format!("{}: {err}", src.display())),
|
||||
}
|
||||
}
|
||||
(done, failed)
|
||||
}
|
||||
|
||||
/// Load the proxy at `path`.
|
||||
pub fn load(path: &Path) -> Result<Arc<Rgb32f>, String> {
|
||||
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
@@ -80,6 +156,43 @@ pub fn load(path: &Path) -> Result<Arc<Rgb32f>, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-smart-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writable_check_does_not_create_missing_parents() {
|
||||
let base = temp("writable");
|
||||
assert!(check_writable(&base.join("new")).is_ok(), "one missing level is created");
|
||||
assert!(base.join("new").is_dir());
|
||||
let err = check_writable(&base.join("gone/deeper")).unwrap_err();
|
||||
assert!(err.contains("not available"), "{err}");
|
||||
assert!(!base.join("gone").exists(), "no fallback onto the current drive");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_moves_leaves_or_discards() {
|
||||
let base = temp("migrate");
|
||||
let (a, b) = (base.join("a"), base.join("b"));
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
std::fs::write(a.join("1.lcsp"), b"one").unwrap();
|
||||
std::fs::write(a.join("note.txt"), b"keep").unwrap();
|
||||
assert_eq!(stats(&a), (1, 3));
|
||||
assert_eq!(migrate(&a, &b, Existing::Leave).0, 0);
|
||||
assert_eq!(stats(&a).0, 1);
|
||||
assert_eq!(migrate(&a, &b, Existing::Move).0, 1);
|
||||
assert_eq!((stats(&a).0, stats(&b).0), (0, 1));
|
||||
assert!(a.join("note.txt").exists(), "only smart previews are touched");
|
||||
assert_eq!(migrate(&b, &a, Existing::Discard).0, 1);
|
||||
assert_eq!(stats(&b).0, 0);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_keeps_the_picture() {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(160, 100);
|
||||
|
||||
@@ -521,3 +521,67 @@ fn dust_spots_are_found_and_healed() {
|
||||
assert!(s.develop_of(id).unwrap().spots.is_empty(), "one undo step");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Smart previews can live outside the library: the folder is saved per library, used for
|
||||
/// building, status and offline rendering, changing it needs an explicit choice for the
|
||||
/// previews already built, and an unavailable folder is an error (never a silent fallback).
|
||||
#[test]
|
||||
fn smart_previews_folder_is_chosen_per_library() {
|
||||
let src = temp_dir("smartloc-src");
|
||||
let lib = temp_dir("smartloc-lib");
|
||||
let away = temp_dir("smartloc-away");
|
||||
write_png(&src.join("a.png"), 7);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let loc = s.execute("library.smartPreviewsLocation", &json!({})).unwrap();
|
||||
assert_eq!(loc["custom"], false);
|
||||
assert_eq!(loc["path"], lib.join("Smart Previews").to_string_lossy().as_ref());
|
||||
s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!(crate::smart::stats(&lib.join("Smart Previews")).0, 1);
|
||||
|
||||
// relative paths and missing drives are refused, and nothing is created on this drive
|
||||
assert!(s.execute("library.smartPreviewsLocation", &json!({"path": "rel/dir"})).is_err());
|
||||
let gone = away.join("unplugged/previews");
|
||||
let err = s.execute("library.smartPreviewsLocation", &json!({"path": gone.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(err.contains("not available"), "{err}");
|
||||
assert!(!away.join("unplugged").exists());
|
||||
// the previews already built need an explicit choice
|
||||
let to = away.join("proxies");
|
||||
let err = s.execute("library.smartPreviewsLocation", &json!({"path": to.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(err.contains("existing"), "{err}");
|
||||
assert_eq!(s.execute("library.smartPreviewsLocation", &json!({})).unwrap()["custom"], false);
|
||||
|
||||
let r = s.execute("library.smartPreviewsLocation", &json!({"path": to.to_string_lossy(), "existing": "move"})).unwrap();
|
||||
assert_eq!((r["custom"].clone(), r["handled"].clone(), r["count"].clone()), (json!(true), json!(1), json!(1)), "{r}");
|
||||
assert_eq!(crate::smart::stats(&lib.join("Smart Previews")).0, 0);
|
||||
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], true);
|
||||
|
||||
// the setting is saved with the library
|
||||
let mut again = Session::new().with_fs();
|
||||
again.open_library(&lib, false).unwrap();
|
||||
assert_eq!(again.execute("library.smartPreviewsLocation", &json!({})).unwrap()["path"], to.to_string_lossy().as_ref());
|
||||
|
||||
// offline original: the render comes from the proxy in the chosen folder
|
||||
std::fs::rename(&src, src.with_extension("offline")).unwrap();
|
||||
again.media.forget(id);
|
||||
again.render_now(id, 48, 32).expect("renders from the smart preview in the chosen folder");
|
||||
|
||||
// the chosen drive goes away: building says so and creates nothing on this drive
|
||||
let drive = away.join("drive");
|
||||
std::fs::create_dir_all(&drive).unwrap();
|
||||
let on_drive = drive.join("proxies");
|
||||
again.execute("library.smartPreviewsLocation", &json!({"path": on_drive.to_string_lossy(), "existing": "leave"})).unwrap();
|
||||
std::fs::remove_dir_all(&drive).unwrap();
|
||||
let loc = again.execute("library.smartPreviewsLocation", &json!({})).unwrap();
|
||||
assert_eq!(loc["available"], false, "{loc}");
|
||||
let err = again.execute("library.smartPreviews", &json!({"ids": [id.0]})).unwrap_err().to_string();
|
||||
assert!(err.contains("not available"), "{err}");
|
||||
assert!(!drive.exists());
|
||||
|
||||
// back to the library's own folder, discarding the proxies left in the first chosen folder
|
||||
let r = again.execute("library.smartPreviewsLocation", &json!({"reset": true})).unwrap();
|
||||
assert_eq!(r["custom"], false, "{r}");
|
||||
assert_eq!(r["path"], lib.join("Smart Previews").to_string_lossy().as_ref());
|
||||
}
|
||||
|
||||
@@ -200,3 +200,26 @@ fn album_source_reports_in_library_state_and_persists_the_view() {
|
||||
assert_eq!(serde_json::to_value(s2.sort).unwrap()["key"], "fileName", "the rest of the view is restored too");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_chips_clear_one_filter_and_total_ignores_the_filter() {
|
||||
let dir = temp_dir("chips");
|
||||
let mut s = open(&dir);
|
||||
let all = s.visible_cloned().len();
|
||||
assert!(all > 1);
|
||||
assert_eq!(s.source_total(), Some(all));
|
||||
s.execute("library.filter", &json!({"rating": 4, "date": "2026-01-16", "text": "x"})).unwrap();
|
||||
// the total is the source's, whatever the filter hides
|
||||
assert_eq!(s.source_total(), Some(all));
|
||||
let chips = crate::filter_chips(&s.filter, &s.catalog);
|
||||
let labels: Vec<&str> = chips.iter().map(|c| c.label.as_str()).collect();
|
||||
assert_eq!(labels, ["Search: “x”", "Rating ≥ 4★", "Date: January 16, 2026"]);
|
||||
// clearing the date chip leaves the other two filters alone
|
||||
s.execute("library.filter", &chips[2].clear).unwrap();
|
||||
assert_eq!(s.filter.date, None);
|
||||
assert_eq!((s.filter.rating, s.filter.text.as_str()), (4, "x"));
|
||||
for c in crate::filter_chips(&s.filter, &s.catalog) {
|
||||
s.execute("library.filter", &c.clear).unwrap();
|
||||
}
|
||||
assert_eq!(s.filter, Default::default());
|
||||
}
|
||||
|
||||
@@ -108,3 +108,96 @@ impl Selection {
|
||||
self.active = Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// One active filter, shown as a removable chip above the grid.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FilterChip {
|
||||
pub label: String,
|
||||
/// `library.filter` patch that clears just this filter.
|
||||
pub clear: serde_json::Value,
|
||||
}
|
||||
|
||||
/// The chips for every constraint in the filter bar / search / sidebar (not the source itself).
|
||||
pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
|
||||
use lightcraft_catalog::{MediaKind, RatingOp};
|
||||
use serde_json::{Value::Null, json};
|
||||
let mut v = Vec::new();
|
||||
let mut add = |label: String, clear: serde_json::Value| v.push(FilterChip { label, clear });
|
||||
if !f.text.trim().is_empty() {
|
||||
add(format!("Search: “{}”", f.text.trim()), json!({"text": ""}));
|
||||
}
|
||||
if f.rating > 0 {
|
||||
let op = match f.rating_op {
|
||||
RatingOp::AtLeast => "≥",
|
||||
RatingOp::Exactly => "=",
|
||||
RatingOp::AtMost => "≤",
|
||||
};
|
||||
add(format!("Rating {op} {}★", f.rating), json!({"rating": 0}));
|
||||
}
|
||||
if let Some(fl) = f.flag {
|
||||
add(format!("Flag: {}", format!("{fl:?}").to_lowercase()), json!({"flag": Null}));
|
||||
}
|
||||
let mut labels = f.labels.clone();
|
||||
labels.extend(f.label.filter(|l| !labels.contains(l)));
|
||||
if !labels.is_empty() {
|
||||
let names: Vec<String> = labels.iter().map(|l| cat.label_name(*l)).collect();
|
||||
add(format!("Label: {}", names.join(" or ")), json!({"label": Null, "labels": []}));
|
||||
}
|
||||
if let Some(k) = f.kind {
|
||||
let n = match k {
|
||||
MediaKind::Image => "Photos",
|
||||
MediaKind::Raw => "Raw",
|
||||
MediaKind::Video => "Videos",
|
||||
};
|
||||
add(format!("Type: {n}"), json!({"kind": Null}));
|
||||
}
|
||||
if let Some(m) = &f.merged {
|
||||
let n = match m.as_str() {
|
||||
"hdr" => "HDR",
|
||||
"panorama" => "Panoramas",
|
||||
"hdrPanorama" => "HDR panoramas",
|
||||
_ => "Merged",
|
||||
};
|
||||
add(format!("Type: {n}"), json!({"merged": Null}));
|
||||
}
|
||||
if let Some(e) = f.edited {
|
||||
add(if e { "Edited".into() } else { "Unedited".into() }, json!({"edited": Null}));
|
||||
}
|
||||
for (name, key, val) in [("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword)] {
|
||||
if let Some(x) = val {
|
||||
add(format!("{name}: {x}"), json!({key: Null}));
|
||||
}
|
||||
}
|
||||
if let Some(d) = &f.date {
|
||||
add(format!("Date: {}", date_label(d)), json!({"date": Null}));
|
||||
}
|
||||
if let Some(d) = &f.imported {
|
||||
add(format!("Imported: {}", date_label(d)), json!({"imported": Null}));
|
||||
}
|
||||
match (&f.date_from, &f.date_to) {
|
||||
(Some(a), Some(b)) => add(format!("Captured {a} – {b}"), json!({"dateFrom": Null, "dateTo": Null})),
|
||||
(Some(a), None) => add(format!("Captured from {a}"), json!({"dateFrom": Null})),
|
||||
(None, Some(b)) => add(format!("Captured until {b}"), json!({"dateTo": Null})),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(rs) = f.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
|
||||
add(format!("Rules: {}", rs.describe()), json!({"ruleSet": Null}));
|
||||
}
|
||||
if !f.only.is_empty() {
|
||||
add(format!("Only {} photos", f.only.len()), json!({"only": []}));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// `2026-01-16` → "January 16, 2026"; `2026-01` → "January 2026"; anything else as is.
|
||||
fn date_label(d: &str) -> String {
|
||||
const MONTHS: [&str; 12] =
|
||||
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
let parts: Vec<&str> = d.split('-').collect();
|
||||
let month = parts.get(1).and_then(|m| m.parse::<usize>().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]);
|
||||
match (parts.as_slice(), month) {
|
||||
([y, _, day], Some(m)) => format!("{m} {}, {y}", day.trim_start_matches('0')),
|
||||
([y, _], Some(m)) => format!("{m} {y}"),
|
||||
_ => d.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,4 +210,23 @@ mod tests {
|
||||
assert_eq!(r["files"].as_array().map(Vec::len), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The export file-name template takes the Rename Photos tokens.
|
||||
#[test]
|
||||
fn export_naming_tokens() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-mcp-export-naming-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut h = Headless::demo();
|
||||
let ids: Vec<u64> = h.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
|
||||
for (id, title) in ids.iter().zip(["Harbour", "Hills"]) {
|
||||
h.call("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [id], "title": title}})).unwrap();
|
||||
h.call("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 4}})).unwrap();
|
||||
}
|
||||
let params = json!({"dir": dir.to_string_lossy(), "ids": ids, "longEdge": 32, "format": "png", "naming": "{title}_{rating}star_{seq:2}", "startNumber": 5});
|
||||
let r = h.call("engine.execute", json!({"command": "app.export", "params": params})).unwrap();
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["Harbour_4star_05.png", "Hills_4star_06.png"], "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,18 @@ fn set_develop_values_and_settings() {
|
||||
assert!(call_tool(&mut b, "set_develop", &json!({"values": {"no.such": 1}})).is_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_needs_a_rect_angle_or_reset() {
|
||||
let mut b = Headless::demo();
|
||||
let first = b.session.visible_cloned()[0];
|
||||
// A guessed parameter must not succeed silently.
|
||||
let r = call_tool(&mut b, "crop", &json!({"id": first.0, "aspect": "1:1"}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
assert!(call_tool(&mut b, "crop", &json!({"id": first.0, "reset": false})).is_error);
|
||||
assert!(!call_tool(&mut b, "crop", &json!({"id": first.0, "rect": [0.1, 0.0, 0.9, 1.0]})).is_error);
|
||||
assert!(!call_tool(&mut b, "crop", &json!({"id": first.0, "reset": true})).is_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_tools_need_the_app() {
|
||||
let mut b = Headless::demo();
|
||||
|
||||
@@ -154,7 +154,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
tool(
|
||||
"crop",
|
||||
"Crop / straighten",
|
||||
"Set the crop of the active photo: `rect` [x0,y0,x1,y1] in normalized coordinates (0..1, origin top-left) of the straightened frame, `angle` in degrees. `reset: true` clears it.",
|
||||
"Set the crop of the active photo: `rect` [x0,y0,x1,y1] in normalized coordinates (0..1, origin top-left) of the straightened frame, `angle` in degrees. `reset: true` clears it. Give at least one of them.",
|
||||
json!({"id": photo_id, "rect": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4}, "angle": {"type": "number"}, "reset": {"type": "boolean"}}),
|
||||
&[],
|
||||
),
|
||||
@@ -192,7 +192,8 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"limitKb": {"type": "integer", "description": "JPEG: largest quality that fits this many KB"},
|
||||
"sharpen": {"type": "string", "enum": ["none", "screen", "matte", "glossy"]},
|
||||
"sharpenAmount": {"type": "string", "enum": ["low", "standard", "high"]},
|
||||
"naming": {"type": "string", "description": "e.g. {name}-{seq}"},
|
||||
"naming": {"type": "string", "description": "File-name template, e.g. {name}-{seq} or {date:%Y-%m-%d}_{title}_{seq:2} (tokens: {name} {num} {seq} {seq:N} {date} {date:%…} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext})"},
|
||||
"startNumber": {"type": "integer", "description": "First {seq} value (default 1)"},
|
||||
"metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]},
|
||||
"removeLocation": {"type": "boolean"},
|
||||
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
|
||||
@@ -515,6 +516,9 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
}
|
||||
None => ToolResult::error("missing `preset`"),
|
||||
},
|
||||
"crop" if args.get("rect").is_none() && args.get("angle").is_none() && args.get("reset").and_then(Value::as_bool) != Some(true) => {
|
||||
ToolResult::error("give `rect`, `angle` or `reset: true`")
|
||||
}
|
||||
"crop" => ToolResult::from(activate(b, args).and_then(|_| {
|
||||
if args.get("reset").and_then(Value::as_bool) == Some(true) {
|
||||
exec(b, "crop.reset", json!({}))
|
||||
@@ -547,6 +551,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
"sharpen",
|
||||
"sharpenAmount",
|
||||
"naming",
|
||||
"startNumber",
|
||||
"metadata",
|
||||
"removeLocation",
|
||||
"watermark",
|
||||
|
||||
@@ -630,11 +630,14 @@ pub fn shortcut_text(sc: &str, mac: bool) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal gap between in-window menu titles. The buttons are frameless, and egui gives
|
||||
/// frameless buttons no padding, so the gap has to come from the layout's item spacing.
|
||||
const TITLE_GAP: f32 = 24.0;
|
||||
|
||||
/// Width of the in-window menu bar's titles.
|
||||
pub fn bar_width(ui: &egui::Ui) -> f32 {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
MENUS.iter().map(|m| ui.painter().layout_no_wrap(m.to_string(), t.font(13.0), t.text).size().x + 16.0).sum::<f32>()
|
||||
+ ui.spacing().item_spacing.x * MENUS.len() as f32
|
||||
MENUS.iter().map(|m| ui.painter().layout_no_wrap(m.to_string(), t.font(13.0), t.text).size().x + TITLE_GAP).sum::<f32>()
|
||||
}
|
||||
|
||||
/// The in-window menu bar (hosts without a native one): one dropdown per menu, or a single
|
||||
@@ -643,17 +646,21 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let bar = menu_bar(app);
|
||||
let font = t.font(13.0);
|
||||
let widths: Vec<f32> = bar.iter().map(|(title, _)| ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x + 16.0).collect();
|
||||
let widths: Vec<f32> =
|
||||
bar.iter().map(|(title, _)| ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x + TITLE_GAP).collect();
|
||||
let total: f32 = widths.iter().sum();
|
||||
let mut clicked: Option<(String, Value)> = None;
|
||||
let start = ui.cursor().left();
|
||||
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
|
||||
if total <= max_width {
|
||||
let saved = ui.spacing().item_spacing.x;
|
||||
ui.spacing_mut().item_spacing.x = TITLE_GAP;
|
||||
for (title, items) in &bar {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(title).font(font.clone()).color(t.text_label)).frame(false));
|
||||
crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect);
|
||||
egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked));
|
||||
}
|
||||
ui.spacing_mut().item_spacing.x = saved;
|
||||
} else {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new("Menu").font(font.clone()).color(t.text_label)).frame(false));
|
||||
crate::widgets::register(ui.ctx(), "menu:all", r.rect);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Active-filter chips under the grid header: one removable chip per constraint (search, rating,
|
||||
//! date, keyword…), "+N more" for what does not fit, and "Clear all". The count next to them says
|
||||
//! how many of the source's photos match, so an empty grid never looks like an empty folder.
|
||||
|
||||
use egui::{Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use lightcraft_engine::FilterChip;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
pub const HEIGHT: f32 = 32.0;
|
||||
const GAP: f32 = 6.0;
|
||||
/// Room kept for "+N more" and "Clear all".
|
||||
const TAIL: f32 = 170.0;
|
||||
|
||||
/// "14 of 120 photos" when a filter narrows the source, else "14 photos".
|
||||
pub fn count_text(matching: usize, total: Option<usize>, filtering: bool) -> String {
|
||||
match total {
|
||||
Some(t) if filtering && t != matching => format!("{matching} of {t} photos"),
|
||||
_ => format!("{matching} photos"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of a chip with `label` (text + the × button).
|
||||
fn chip_width(ui: &egui::Ui, label: &str, font: egui::FontId) -> f32 {
|
||||
ui.painter().layout_no_wrap(label.to_string(), font, egui::Color32::WHITE).size().x + 12.0 + 22.0
|
||||
}
|
||||
|
||||
/// Draws the strip; does nothing without chips.
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) {
|
||||
if chips.is_empty() {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEIGHT), Sense::hover());
|
||||
ui.painter().rect_filled(bar, 0.0, t.canvas);
|
||||
register(ui.ctx(), "filterchips", bar);
|
||||
let font = t.font(12.0);
|
||||
let right = bar.right() - 16.0;
|
||||
let mut x = bar.left() + 20.0;
|
||||
let mut shown = 0;
|
||||
for (i, c) in chips.iter().enumerate() {
|
||||
let w = chip_width(ui, &c.label, font.clone());
|
||||
// always show the first chip; keep room for the tail unless this is the last one that fits
|
||||
let room = right - x - if i + 1 == chips.len() { 90.0 } else { TAIL };
|
||||
if i > 0 && w > room {
|
||||
break;
|
||||
}
|
||||
let r = Rect::from_min_size(pos2(x, bar.center().y - 11.0), vec2(w.min(right - x), 22.0));
|
||||
ui.painter().rect(r, 11.0, t.field, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), StrokeKind::Inside);
|
||||
let text_r = Rect::from_min_max(r.min, pos2(r.right() - 22.0, r.max.y));
|
||||
ui.painter().with_clip_rect(text_r).text(pos2(r.left() + 10.0, r.center().y), egui::Align2::LEFT_CENTER, &c.label, font.clone(), t.text);
|
||||
let xr = Rect::from_center_size(pos2(r.right() - 12.0, r.center().y), vec2(18.0, 18.0));
|
||||
let resp = ui.interact(xr, egui::Id::new(("filter-chip-x", i)), Sense::click()).on_hover_text("Remove this filter");
|
||||
register(ui.ctx(), format!("chip:{i}"), xr);
|
||||
let col = if resp.hovered() { t.text } else { t.text_dim };
|
||||
let m = 3.5;
|
||||
let (a, b) = (xr.center() - vec2(m, m), xr.center() + vec2(m, m));
|
||||
ui.painter().line_segment([a, b], Stroke::new(1.3, col));
|
||||
ui.painter().line_segment([pos2(a.x, b.y), pos2(b.x, a.y)], Stroke::new(1.3, col));
|
||||
if resp.clicked() {
|
||||
let _ = app.run("library.filter", c.clear.clone());
|
||||
if c.clear.get("text").is_some() {
|
||||
app.ui.search.clear();
|
||||
}
|
||||
}
|
||||
x = r.right() + GAP;
|
||||
shown = i + 1;
|
||||
}
|
||||
let mut child = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(Rect::from_min_max(pos2(x, bar.top() + 4.0), pos2(right, bar.bottom() - 4.0)))
|
||||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||||
);
|
||||
child.spacing_mut().item_spacing.x = 8.0;
|
||||
let hidden = &chips[shown..];
|
||||
if !hidden.is_empty() {
|
||||
let more = crate::widgets::text_button(&mut child, "chipsMore", &format!("+{} more", hidden.len()), false);
|
||||
egui::Popup::menu(&more).show(|ui| {
|
||||
for c in hidden {
|
||||
if ui.button(format!("{} ✕", c.label)).on_hover_text("Remove this filter").clicked() {
|
||||
let _ = app.run("library.filter", c.clear.clone());
|
||||
if c.clear.get("text").is_some() {
|
||||
app.ui.search.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if crate::widgets::text_button(&mut child, "chipsClearAll", "Clear all", false).clicked() {
|
||||
app.ui.search.clear();
|
||||
let _ = app.run("library.clearFilter", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::count_text;
|
||||
|
||||
#[test]
|
||||
fn count_says_matching_of_total_only_when_a_filter_narrows() {
|
||||
assert_eq!(count_text(0, Some(14), true), "0 of 14 photos");
|
||||
assert_eq!(count_text(14, Some(14), true), "14 photos");
|
||||
assert_eq!(count_text(5, Some(14), false), "5 photos");
|
||||
assert_eq!(count_text(3, None, true), "3 photos");
|
||||
}
|
||||
}
|
||||
@@ -305,7 +305,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999)));
|
||||
ui.label(
|
||||
egui::RichText::new("Tokens: {name} {seq} {seq:3} {date} {date:%Y-%m-%d} {camera} {title}. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…")
|
||||
egui::RichText::new("Tokens: {name} {num} {seq} {seq:3} {date} {date:%Y-%m-%d} {folder} {camera} {lens} {iso} {rating} {title} {creator}. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…")
|
||||
.color(t.text_dim),
|
||||
);
|
||||
let preview = app.session.execute("photo.renamePreview", &json!({"template": template, "start": start})).unwrap_or_default();
|
||||
@@ -577,9 +577,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None")], &mut opts.dng_compression);
|
||||
}
|
||||
field(ui, "File name", |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq} · {date}").desired_width(f32::INFINITY))
|
||||
ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(f32::INFINITY))
|
||||
});
|
||||
if opts.naming.contains("{seq}") {
|
||||
if opts.naming.contains("{seq") {
|
||||
let mut v = opts.start_number as f64;
|
||||
if num(ui, &START_NUMBER, &mut v) {
|
||||
opts.start_number = v as u32;
|
||||
|
||||
@@ -32,7 +32,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
|
||||
ui.painter().rect_filled(hr, 0.0, t.canvas);
|
||||
let sel_n = app.session.selection.ids.len();
|
||||
let cnt = if sel_n > 1 { format!("{sel_n} of {} photos", ids.len()) } else { format!("{} photos", ids.len()) };
|
||||
let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog);
|
||||
let total = app.session.source_total();
|
||||
let counted = super::chips::count_text(ids.len(), total, !chips.is_empty());
|
||||
let cnt = if sel_n > 1 { format!("{sel_n} selected · {counted}") } else { counted };
|
||||
match app.session.browse.clone().filter(|_| app.session.source == lightcraft_engine::LibrarySource::Folder) {
|
||||
Some(b) => folder_header(app, ui, hr, &b, &ids, &cnt),
|
||||
None => {
|
||||
@@ -41,12 +44,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim);
|
||||
}
|
||||
}
|
||||
super::chips::show(app, ui, &chips);
|
||||
if app.ui.filter_bar {
|
||||
super::filterbar::show(app, ui);
|
||||
}
|
||||
app.canvas_rect = Some(ui.max_rect());
|
||||
if ids.is_empty() {
|
||||
if app.session.filter != Default::default() {
|
||||
if !chips.is_empty() {
|
||||
let body = match total {
|
||||
Some(n) if n > 0 => format!("{n} photos in {} are hidden by the filters above", app.session.source.label(&app.session.catalog)),
|
||||
_ => "Remove a filter above, or choose Clear all".to_string(),
|
||||
};
|
||||
super::empty_message(ui, ui.max_rect(), "No photos match the active filters", &body);
|
||||
} else if app.session.filter != Default::default() {
|
||||
super::empty_message(
|
||||
ui,
|
||||
ui.max_rect(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Window regions and panels.
|
||||
|
||||
pub mod bottombar;
|
||||
pub mod chips;
|
||||
pub mod compare;
|
||||
pub mod crop_overlay;
|
||||
pub mod detail;
|
||||
|
||||
@@ -376,6 +376,92 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let _ = app.run("library.clearPreviews", json!({}));
|
||||
}
|
||||
});
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
smart_previews(app, ui, t);
|
||||
}
|
||||
|
||||
/// Where this library keeps its smart previews (the offline-editing proxies, which can be large):
|
||||
/// the effective folder, what is in it, and choosing another one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
// listing a big folder every frame would be slow: refresh every 2 s and after a change
|
||||
let cache = egui::Id::new("smart-location");
|
||||
let now = ui.input(|i| i.time);
|
||||
let loc: Value = match ui.data(|d| d.get_temp::<(f64, Value)>(cache)) {
|
||||
Some((at, v)) if now - at < 2.0 => v,
|
||||
_ => {
|
||||
let v = app.run("library.smartPreviewsLocation", json!({})).unwrap_or(Value::Null);
|
||||
ui.data_mut(|d| d.insert_temp(cache, (now, v.clone())));
|
||||
v
|
||||
}
|
||||
};
|
||||
if loc.is_null() {
|
||||
return;
|
||||
}
|
||||
heading(ui, t, "Smart previews");
|
||||
let path = loc["path"].as_str().unwrap_or_default().to_string();
|
||||
let available = loc["available"].as_bool().unwrap_or(true);
|
||||
let custom = loc["custom"].as_bool().unwrap_or(false);
|
||||
row(ui, t, "Folder", |ui| {
|
||||
let text = format!("{path}{}", if custom { "" } else { " (library default)" });
|
||||
ui.add(egui::Label::new(RichText::new(text.clone()).color(if available { t.text } else { t.reject })).truncate()).on_hover_text(text);
|
||||
});
|
||||
let (count, bytes) = (loc["count"].as_u64().unwrap_or(0), loc["bytes"].as_u64().unwrap_or(0));
|
||||
if available {
|
||||
hint(ui, t, &format!("{count} smart preview{} · {:.1} MB", if count == 1 { "" } else { "s" }, bytes as f64 / 1048576.0));
|
||||
} else {
|
||||
hint(
|
||||
ui,
|
||||
t,
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.",
|
||||
);
|
||||
}
|
||||
// what happens to the previews already built when the folder changes
|
||||
let mode_id = egui::Id::new("smart-existing");
|
||||
let mut mode: u8 = ui.data(|d| d.get_temp(mode_id)).unwrap_or(0);
|
||||
if count > 0 {
|
||||
row(ui, t, "Existing previews", |ui| {
|
||||
if choices(ui, "settingsSmartExisting", &[(0u8, "Move them"), (1, "Leave them"), (2, "Delete them")], &mut mode) {
|
||||
ui.data_mut(|d| d.insert_temp(mode_id, mode));
|
||||
}
|
||||
});
|
||||
}
|
||||
let existing = ["move", "leave", "discard"][mode.min(2) as usize];
|
||||
let mut result = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(LABEL_W);
|
||||
if app.services.pick_folder.is_some() {
|
||||
let r = ui.button("Choose Folder…");
|
||||
register(ui.ctx(), "button:settingsSmartChoose", r.rect);
|
||||
if r.clicked()
|
||||
&& let Some(dir) = app.services.pick_folder.as_mut().and_then(|f| f())
|
||||
{
|
||||
result = Some(app.run("library.smartPreviewsLocation", json!({"path": dir, "existing": existing})));
|
||||
}
|
||||
}
|
||||
let r = ui.add_enabled(custom, egui::Button::new("Use Library Folder"));
|
||||
register(ui.ctx(), "button:settingsSmartReset", r.rect);
|
||||
if r.clicked() {
|
||||
result = Some(app.run("library.smartPreviewsLocation", json!({"reset": true, "existing": existing})));
|
||||
}
|
||||
});
|
||||
if let Some(r) = result {
|
||||
ui.data_mut(|d| d.remove::<(f64, Value)>(cache));
|
||||
match r {
|
||||
Ok(v) => {
|
||||
let failed = v["failed"].as_array().map_or(0, Vec::len);
|
||||
let msg = match (existing, v["handled"].as_u64().unwrap_or(0)) {
|
||||
(_, 0) => "Smart previews folder changed".to_string(),
|
||||
("move", n) => format!("Smart previews folder changed; moved {n}"),
|
||||
("discard", n) => format!("Smart previews folder changed; deleted {n}"),
|
||||
_ => "Smart previews folder changed".to_string(),
|
||||
};
|
||||
app.toast(ui.ctx(), if failed > 0 { format!("{msg} ({failed} failed)") } else { msg });
|
||||
}
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
hint(ui, t, "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------- Interface
|
||||
|
||||
@@ -94,6 +94,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
t.icon
|
||||
},
|
||||
);
|
||||
// badge: how many filters are on, even with the filter bar closed
|
||||
let active = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).len();
|
||||
if active > 0 {
|
||||
let c = fr.right_top() + vec2(-3.0, 8.0);
|
||||
ui.painter().circle_filled(c, 7.0, t.accent);
|
||||
ui.painter().text(c, Align2::CENTER_CENTER, active.to_string(), t.semibold(9.5), t.canvas);
|
||||
}
|
||||
let fresp = fresp.on_hover_text(if active > 0 {
|
||||
format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" })
|
||||
} else {
|
||||
"Filter bar".into()
|
||||
});
|
||||
if fresp.clicked() {
|
||||
let _ = app.run("view.filterBar", json!({}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
ArtCraft brand assets: license
|
||||
|
||||
The ArtCraft name, wordmark and mark (the "ArtCraft Marks"), including every file in this
|
||||
directory in every color, size and format, are owned by the ArtCraft Team. They are trademarks
|
||||
and are NOT open source. They are not covered by LightCraft's MIT OR Apache-2.0 license, and nothing in
|
||||
that license, in NOTICE or in any other file of this repository grants any right to them.
|
||||
|
||||
Permission
|
||||
|
||||
The ArtCraft Team grants a limited, non-exclusive, non-transferable, revocable, royalty-free
|
||||
permission to use the ArtCraft Marks only:
|
||||
|
||||
1. as part of copies of this repository; and
|
||||
2. within LightCraft itself, its documentation and its official builds, to identify LightCraft as made
|
||||
by the ArtCraft team and community and to link to getartcraft.com and its community.
|
||||
|
||||
Conditions
|
||||
|
||||
The permission holds only while every one of these is true:
|
||||
|
||||
- The marks are used exactly as supplied in this directory. Do not redraw, recolor, crop,
|
||||
stretch, rotate, animate, outline or add effects to them, extract parts of them, or combine
|
||||
them with any other name, mark or artwork.
|
||||
- They are used only in the context of LightCraft, and only to say that the ArtCraft team makes it.
|
||||
They must never suggest that the ArtCraft Team made, sponsors, endorses, certifies or supports
|
||||
any other product, service, fork, modified version, plug-in, organization or person.
|
||||
- Neither the marks nor the ArtCraft name appear in the name, logo, icon, domain, social media
|
||||
handle, app-store listing, advertising or marketing of anything other than LightCraft.
|
||||
|
||||
Forks and modified versions
|
||||
|
||||
If you distribute or publish a modified version of LightCraft, or any work derived from it, you must
|
||||
remove the ArtCraft Marks from it (or replace them with your own) and must not call it ArtCraft or
|
||||
present it as an ArtCraft product. You may say, in plain text, that your work is based on LightCraft by
|
||||
the ArtCraft team. A fork kept only to propose changes back to this repository may keep the marks
|
||||
unchanged.
|
||||
|
||||
Everything else
|
||||
|
||||
Any use not expressly permitted here needs prior written permission from the ArtCraft Team
|
||||
(https://getartcraft.com/). The permission ends automatically for anyone who breaks these terms.
|
||||
All other rights are reserved.
|
||||
+1
-1
@@ -83,7 +83,7 @@ During development you can also point the client at `cargo run --release -p ligh
|
||||
| `get_develop {id?}` | Full develop-settings JSON |
|
||||
| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable |
|
||||
| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) |
|
||||
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle |
|
||||
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true` |
|
||||
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |
|
||||
| `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif`; `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation; `dontEnlarge` defaults to true |
|
||||
|
||||
|
||||
+4
-4
@@ -120,7 +120,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
|---|---|---|---|---|---|
|
||||
| LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | |
|
||||
| LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day |
|
||||
| LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with counts; a row filters by that date |
|
||||
| LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters |
|
||||
| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort |
|
||||
| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) |
|
||||
| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs` | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`) |
|
||||
@@ -137,7 +137,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" |
|
||||
| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | |
|
||||
| LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) |
|
||||
| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` | template tokens {name} {seq:N} {date:%Y%m%d} {camera} {title}; preview; renames files on disk with their XMP sidecars, never overwriting (-1, -2… suffixes), rolls back on failure; undo/redo move the files; virtual copies follow |
|
||||
| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming); preview; renames files on disk with their XMP sidecars, never overwriting (-1, -2… suffixes), rolls back on failure; undo/redo move the files; virtual copies follow |
|
||||
| LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time” |
|
||||
| LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ✅ | `cmd:app.showInFinder` | ⌘R (see MENU-FILE-SHOWFINDER) |
|
||||
| LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | |
|
||||
@@ -418,7 +418,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | |
|
||||
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | |
|
||||
| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text or `image` + `imageWidth`), `crates/engine/src/export.rs` (`Watermark`) | text (size, colour, shadow) or a graphic with transparency (width as % of the photo, converted to the output colour space); position, inset, opacity |
|
||||
| LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`: `{name}`, `{seq}`, `{date}`; `startNumber`) | free-form template rather than a list of named schemes |
|
||||
| LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes |
|
||||
| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker) | folder picker on the desktop; existing files get `-2`, `-3`… by default |
|
||||
| LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet |
|
||||
| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output |
|
||||
@@ -463,7 +463,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| LR-CLOUD-SYNC | Cloud sync | OOS | 🚫 | | |
|
||||
| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs` | File ▸ Previews ▸ Build / Discard Smart Previews: ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status |
|
||||
| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:library.smartPreviewsLocation`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs`, `crates/ui-egui/src/panels/settings.rs` | File ▸ Previews ▸ Build / Discard Smart Previews: ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status; the proxy folder is chosen per library (Settings → Performance → Smart previews, saved in the library): the effective path, count and size are shown, changing it needs a choice for the proxies already built (move / leave / delete), and an unavailable or unwritable folder is an error, never a fallback to the library drive. The thumbnail cache stays in the library |
|
||||
| LR-AI-UPDATE-INDICATOR | AI-settings update indicator | P2 | ⬜ | | |
|
||||
| LR-AI-CREDITS | Generative credits | OOS | 🚫 | | |
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- Colour-label filter with several labels at once; expandable folder tree in Local.
|
||||
- Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.
|
||||
- Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.
|
||||
- Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.
|
||||
|
||||
### Editing
|
||||
- Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.
|
||||
|
||||
Reference in New Issue
Block a user