Skip to content

Commit a8b1ee2

Browse files
committed
kei 0.20.4
1 parent f9e34e0 commit a8b1ee2

12 files changed

Lines changed: 136 additions & 114 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ src-tauri/target/
1111
src-tauri/gen/
1212

1313
# Sidecar binaries (generated by scripts/prepare-sidecar.js)
14-
src-tauri/binaries/
14+
src-tauri/binaries/*
1515
!src-tauri/binaries/.kei-version
1616

1717
# macOS

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ npm run dev
5252

5353
The first build takes a few minutes (Tauri compiles the WebView bindings). Subsequent runs are fast.
5454

55-
`prepare-sidecar` is a no-op if the binary is already present. The downloaded version is pinned in `src-tauri/binaries/.kei-version` so cross-platform builds always use the same release.
55+
`prepare-sidecar` is a no-op if the binary is already present and newer than the pin file. The downloaded version is pinned in `src-tauri/binaries/.kei-version` so cross-platform builds always use the same release.
5656

5757
To update the bundled kei to the latest release:
5858

@@ -171,21 +171,23 @@ domain = "com" # or "cn" for China
171171

172172
[download]
173173
directory = "~/Photos/iCloud"
174-
threads_num = 10
174+
threads = 10
175175
folder_structure = "%Y/%m/%d" # unfiled photos
176176
folder_structure_albums = "{album}/%Y/%m" # user albums
177177
folder_structure_smart_folders = "{smart-folder}" # Apple smart folders
178-
set_exif_datetime = false
179178

180179
[download.retry]
181-
max_download_attempts = 10
180+
per_asset = 10
181+
182+
[metadata]
183+
set_exif_datetime = false
182184

183185
[filters]
184-
skip_videos = false
185186
libraries = ["primary"]
186187
albums = ["Vacation", "!Screenshots"]
187188
smart_folders = ["Favorites"]
188189
unfiled = true
190+
media = ["photos", "videos", "live-photos"]
189191
recent = 0 # 0 = all
190192

191193
[watch]

agents/agents.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Under the hood, it is a Tauri v2 cross-platform desktop GUI that wraps the [kei]
1010

1111
**Supported platforms:** macOS (primary), Windows 10+, Linux.
1212

13-
**Rule:** never modify kei's source or vendor it. All behaviour changes go through the GUI layer or by passing CLI flags to the kei binary.
13+
**Rule:** never modify kei's source or vendor it. Durable sync behaviour goes through kei's TOML config; only pass CLI flags that are still listed by the bundled `kei sync --help`.
1414

1515
## Tech stack
1616

@@ -84,7 +84,7 @@ There is exactly one `AppState` instance, registered with `.manage()` at startup
8484

8585
### AppSettings vs KeiConfig
8686

87-
`KeiConfig` maps directly to kei's `config.toml` and is read/written by the kei binary itself. `AppSettings` is a separate file (`~/.config/photoharbor/settings.toml`) that holds UI preferences such as `use_system_kei`, `all_albums`, and legacy folder-template fallbacks. Kei v0.13 stores unfiled, album, and smart-folder templates as `[download].folder_structure`, `[download].folder_structure_albums`, and `[download].folder_structure_smart_folders`; sync launch also passes those values with the matching v0.13 CLI flags.
87+
`KeiConfig` maps directly to kei's `config.toml` and is read/written by the kei binary itself. `AppSettings` is a separate file (`~/.config/photoharbor/settings.toml`) that holds UI preferences such as `use_system_kei`, `all_albums`, and legacy folder-template fallbacks. kei v0.20 keeps durable sync settings in TOML: folder templates live under `[download]`, album/smart-folder/library selectors live under `[filters]`, media filtering is `[filters].media`, retry limits are `[download.retry].per_asset`, and EXIF metadata toggles live under `[metadata]`. `start_sync` should only pass one-run flags that still exist in `kei sync --help` such as `--friendly`, `--recent`, `--dry-run`, or `--retry-failed`.
8888

8989
### Tauri events emitted from Rust → JS
9090

@@ -118,7 +118,7 @@ The kei binary is bundled using Tauri's `externalBin` mechanism. Before building
118118
npm run prepare-sidecar
119119
```
120120

121-
This downloads the pinned kei release (from `src-tauri/binaries/.kei-version`) to `src-tauri/binaries/kei-<target-triple>[.exe]`. The binaries themselves are gitignored; the version file is committed. Pass `--force` to fetch latest and update the pin.
121+
This downloads the pinned kei release (from `src-tauri/binaries/.kei-version`) to `src-tauri/binaries/kei-<target-triple>[.exe]`. The binaries themselves are gitignored; the version file is committed. Pass `--force` to fetch latest and update the pin. If the pin file is newer than an existing sidecar, the script re-downloads that sidecar.
122122

123123
To update kei: `node scripts/prepare-sidecar.js --force`, then commit `.kei-version`.
124124

@@ -186,8 +186,9 @@ The GUI never writes to the database.
186186
KeiConfig {
187187
log_level: Option<String>,
188188
auth: Option<AuthConfig>, // username, domain
189-
download: Option<DownloadConfig>, // directory, threads_num, folder structures, retry, set_exif_datetime
190-
filters: Option<FiltersConfig>, // skip_videos, skip_photos, libraries, albums, smart_folders, unfiled, recent
189+
download: Option<DownloadConfig>, // directory, threads, folder structures, retry
190+
metadata: Option<MetadataConfig>, // EXIF/XMP metadata toggles
191+
filters: Option<FiltersConfig>, // libraries, albums, smart_folders, unfiled, media, recent
191192
watch: Option<WatchConfig>, // interval
192193
}
193194
```

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "photoharbor",
3-
"version": "0.14.2",
3+
"version": "0.20.4",
44
"description": "Cloud Photo Downloader for macOS, Windows, and Linux",
55
"scripts": {
66
"tauri": "tauri",

scripts/prepare-sidecar.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,17 @@ function extract(archivePath, ext, outDir) {
143143
const destBin = targetIsWindows ? `kei-${triple}.exe` : `kei-${triple}`;
144144
const destPath = path.join(BIN_DIR, destBin);
145145

146-
// Check if already up-to-date (unless --force)
146+
// Check if already up-to-date (unless --force). If the version pin was
147+
// changed after this binary was created, re-download the target binary.
147148
if (!force && fs.existsSync(destPath)) {
148-
console.log(`Sidecar already present: ${destPath}`);
149-
console.log("Run with --force to re-download.");
150-
return;
149+
const pinIsNewer = fs.existsSync(VERSION_FILE)
150+
&& fs.statSync(VERSION_FILE).mtimeMs > fs.statSync(destPath).mtimeMs + 1000;
151+
if (!pinIsNewer) {
152+
console.log(`Sidecar already present: ${destPath}`);
153+
console.log("Run with --force to re-download.");
154+
return;
155+
}
156+
console.log(`Sidecar is older than ${VERSION_FILE}; re-downloading ${destBin}.`);
151157
}
152158

153159
console.log(`Target triple: ${triple}`);

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "photoharbor"
3-
version = "0.14.2"
3+
version = "0.20.4"
44
edition = "2021"
55

66
[build-dependencies]

src-tauri/binaries/.kei-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
v0.20.4

src-tauri/src/main.rs

Lines changed: 56 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ use tokio::sync::Mutex;
1515

1616
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
1717
pub struct KeiConfig {
18+
pub data_dir: Option<String>,
1819
pub log_level: Option<String>,
1920
pub auth: Option<AuthConfig>,
2021
pub download: Option<DownloadConfig>,
22+
pub metadata: Option<MetadataConfig>,
2123
pub filters: Option<FiltersConfig>,
2224
pub watch: Option<WatchConfig>,
2325
}
@@ -31,28 +33,45 @@ pub struct AuthConfig {
3133
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
3234
pub struct DownloadConfig {
3335
pub directory: Option<String>,
34-
pub threads_num: Option<u32>,
36+
#[serde(alias = "threads_num")]
37+
pub threads: Option<u32>,
3538
pub folder_structure: Option<String>,
3639
pub folder_structure_albums: Option<String>,
3740
pub folder_structure_smart_folders: Option<String>,
41+
#[serde(default, skip_serializing)]
3842
pub set_exif_datetime: Option<bool>,
3943
pub retry: Option<DownloadRetryConfig>,
4044
}
4145

4246
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
4347
pub struct DownloadRetryConfig {
44-
pub max_download_attempts: Option<u32>,
48+
pub per_transfer: Option<u32>,
49+
#[serde(alias = "max_download_attempts")]
50+
pub per_asset: Option<u32>,
51+
}
52+
53+
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
54+
pub struct MetadataConfig {
55+
pub set_exif_datetime: Option<bool>,
56+
pub set_exif_rating: Option<bool>,
57+
pub set_exif_gps: Option<bool>,
58+
pub set_exif_description: Option<bool>,
59+
pub embed_xmp: Option<bool>,
60+
pub xmp_sidecar: Option<bool>,
4561
}
4662

4763
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
4864
pub struct FiltersConfig {
65+
#[serde(default, skip_serializing)]
4966
pub skip_videos: Option<bool>,
67+
#[serde(default, skip_serializing)]
5068
pub skip_photos: Option<bool>,
5169
pub libraries: Option<Vec<String>>,
5270
pub albums: Option<Vec<String>>,
5371
pub exclude_albums: Option<Vec<String>>,
5472
pub smart_folders: Option<Vec<String>>,
5573
pub unfiled: Option<bool>,
74+
pub media: Option<Vec<String>>,
5675
pub recent: Option<u32>,
5776
}
5877

@@ -411,12 +430,14 @@ async fn get_config() -> Result<KeiConfig, String> {
411430
return Ok(KeiConfig::default());
412431
}
413432
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
414-
toml::from_str(&content).map_err(|e| e.to_string())
433+
let mut config: KeiConfig = toml::from_str(&content).map_err(|e| e.to_string())?;
434+
normalize_v020_config(&mut config);
435+
Ok(config)
415436
}
416437

417438
#[tauri::command]
418439
async fn save_config(mut config: KeiConfig) -> Result<(), String> {
419-
normalize_v013_filters(&mut config);
440+
normalize_v020_config(&mut config);
420441
let path = config_path()?;
421442
if let Some(parent) = path.parent() {
422443
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
@@ -425,13 +446,21 @@ async fn save_config(mut config: KeiConfig) -> Result<(), String> {
425446
write_text_if_changed(&path, &content)
426447
}
427448

428-
fn normalize_v013_filters(config: &mut KeiConfig) {
449+
fn normalize_v020_config(config: &mut KeiConfig) {
450+
if let Some(download) = config.download.as_mut() {
451+
if let Some(set_exif_datetime) = download.set_exif_datetime.take() {
452+
let metadata = config.metadata.get_or_insert_with(MetadataConfig::default);
453+
if metadata.set_exif_datetime.is_none() {
454+
metadata.set_exif_datetime = Some(set_exif_datetime);
455+
}
456+
}
457+
}
458+
429459
let Some(filters) = config.filters.as_mut() else {
430460
return;
431461
};
432462

433463
let mut albums = filters.albums.take().unwrap_or_default();
434-
albums.retain(|album| !album.eq_ignore_ascii_case("all"));
435464

436465
if let Some(exclude_albums) = filters.exclude_albums.take() {
437466
albums.extend(
@@ -453,6 +482,25 @@ fn normalize_v013_filters(config: &mut KeiConfig) {
453482
} else {
454483
Some(albums)
455484
};
485+
486+
if filters.media.is_none() {
487+
let skip_photos = filters.skip_photos.unwrap_or(false);
488+
let skip_videos = filters.skip_videos.unwrap_or(false);
489+
490+
if skip_photos || skip_videos {
491+
let mut media = Vec::new();
492+
if !skip_photos {
493+
media.push("photos".to_string());
494+
}
495+
if !skip_videos {
496+
media.push("videos".to_string());
497+
}
498+
if !skip_photos && !skip_videos {
499+
media.push("live-photos".to_string());
500+
}
501+
filters.media = Some(media);
502+
}
503+
}
456504
}
457505

458506
#[tauri::command]
@@ -863,56 +911,6 @@ async fn start_sync(app: AppHandle, state: State<'_, AppState>) -> Result<(), St
863911
let kei_bin = resolve_kei_bin().await?;
864912
let app_settings = get_app_settings().await.unwrap_or_default();
865913

866-
// If kei's TOML still has albums=["all"] from a previous save, strip it out
867-
// so kei doesn't try to find a literal album named "all".
868-
let kei_cfg = get_config().await.unwrap_or_default();
869-
let toml_had_all = kei_cfg
870-
.filters
871-
.as_ref()
872-
.and_then(|f| f.albums.as_ref())
873-
.is_some_and(|albums| albums.iter().any(|a| a.eq_ignore_ascii_case("all")));
874-
875-
if toml_had_all {
876-
let mut clean = kei_cfg.clone();
877-
if let Some(ref mut f) = clean.filters {
878-
let retained = f
879-
.albums
880-
.take()
881-
.unwrap_or_default()
882-
.into_iter()
883-
.filter(|a| !a.eq_ignore_ascii_case("all"))
884-
.collect::<Vec<_>>();
885-
f.albums = if retained.is_empty() {
886-
None
887-
} else {
888-
Some(retained)
889-
};
890-
}
891-
if let (Ok(path), Ok(content)) = (config_path(), toml::to_string_pretty(&clean)) {
892-
let _ = write_text_if_changed(&path, &content);
893-
}
894-
}
895-
896-
// Compute folder templates from AppSettings / config; passed as CLI
897-
// arg so we never touch kei's config.toml (which would trigger "Config changed
898-
// — verifying all files" on every sync due to TOML serialization differences).
899-
let config_download = kei_cfg.download.as_ref();
900-
let kei_folder_structure = app_settings.folder_structure.as_deref();
901-
let kei_folder_structure = config_download
902-
.and_then(|d| d.folder_structure.as_deref())
903-
.or(kei_folder_structure)
904-
.unwrap_or("%Y/%m");
905-
let kei_album_folder_structure_fallback = app_settings.album_folder_structure.as_deref();
906-
let kei_album_folder_structure = config_download
907-
.and_then(|d| d.folder_structure_albums.as_deref())
908-
.or(kei_album_folder_structure_fallback)
909-
.unwrap_or("{album}");
910-
let kei_smart_folder_structure_fallback = app_settings.smart_folder_structure.as_deref();
911-
let kei_smart_folder_structure = config_download
912-
.and_then(|d| d.folder_structure_smart_folders.as_deref())
913-
.or(kei_smart_folder_structure_fallback)
914-
.unwrap_or("{smart-folder}");
915-
916914
// Clear any stale lock file left by a previous hard-quit before launching kei.
917915
// This avoids the "Session lock held by another instance" error on restart.
918916
if delete_kei_lock().await {
@@ -923,18 +921,6 @@ async fn start_sync(app: AppHandle, state: State<'_, AppState>) -> Result<(), St
923921

924922
let mut cmd = Command::new(&kei_bin);
925923
cmd.arg("sync");
926-
if !kei_folder_structure.is_empty() {
927-
cmd.args(["--folder-structure", &kei_folder_structure]);
928-
}
929-
if !kei_album_folder_structure.is_empty() {
930-
cmd.args(["--folder-structure-albums", &kei_album_folder_structure]);
931-
}
932-
if !kei_smart_folder_structure.is_empty() {
933-
cmd.args([
934-
"--folder-structure-smart-folders",
935-
&kei_smart_folder_structure,
936-
]);
937-
}
938924
if let Some(extra) = &app_settings.extra_args {
939925
cmd.args(extra.split_whitespace());
940926
}
@@ -1819,8 +1805,8 @@ async fn browse_photos(
18191805
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
18201806
pub struct AppSettings {
18211807
pub use_system_kei: Option<bool>,
1822-
/// When true, passes `-a all` to `kei sync` rather than storing ["all"] in
1823-
/// kei's TOML (which kei interprets as a literal album name and errors).
1808+
/// UI preference for the "all albums" selector; kei v0.20 stores this as
1809+
/// `[filters].albums = ["all"]` in TOML.
18241810
pub all_albums: Option<bool>,
18251811
/// Base folder structure pattern for non-album photos (e.g. "%Y/%m").
18261812
pub folder_structure: Option<String>,

0 commit comments

Comments
 (0)