diff --git a/crates/pdfs-core/src/takeout.rs b/crates/pdfs-core/src/takeout.rs index 8df5db3..db9106f 100644 --- a/crates/pdfs-core/src/takeout.rs +++ b/crates/pdfs-core/src/takeout.rs @@ -364,9 +364,13 @@ fn assemble(entries: Vec, sidecars: &HashMap>) -> Take continue; }; - let sidecar = per_folder_sidecars + let found = per_folder_sidecars .get(&folder) - .and_then(|in_folder| find_sidecar(&file_name, in_folder)) + .and_then(|in_folder| find_sidecar(&file_name, in_folder)); + // An edited copy keeps its own name: the title is its original's. + let title_is_ours = found.is_some_and(|((rule, _), _)| rule != EDITED_COPY); + let sidecar = found + .map(|(_, body)| body) .and_then(|body| serde_json::from_slice::(body).ok()) .unwrap_or_default(); @@ -388,6 +392,7 @@ fn assemble(entries: Vec, sidecars: &HashMap>) -> Take let name = sidecar .title .as_deref() + .filter(|_| title_is_ours) .map(str::trim) .filter(|title| !title.is_empty() && same_extension(title, &file_name)) .unwrap_or(&file_name) @@ -627,15 +632,25 @@ fn swap_duplicate_marker(file_name: &str) -> Option { Some(format!("{}{}{}", &file_name[..marker_start], tail, marker)) } -/// Find the sidecar belonging to `file_name` among the JSON files of its folder. +/// How long Google lets a sidecar's file name get, `.json` included. A longer +/// one is cut, into its suffix first and then into the photo name. +const SIDECAR_NAME_CAP: usize = 51; + +/// The rank [`sidecar_rank`] gives an edited copy that shares its original's +/// sidecar. Its date is the original's, but its name is its own. +const EDITED_COPY: u8 = 4; + +/// Find the sidecar belonging to `file_name` among the JSON files of its folder, +/// with the rank it matched at. /// /// Tried in order of confidence: the exact name, the `(N)`-swapped name, an -/// unknown trailing suffix, then a prefix match for the case where truncation -/// cut into the photo name itself. The best-ranked candidate in the folder wins. +/// unknown trailing suffix, a prefix match for the case where truncation cut +/// into the photo name itself, and last an edited copy's original. The +/// best-ranked candidate in the folder wins. fn find_sidecar<'a>( file_name: &str, in_folder: &BTreeMap>, -) -> Option<&'a Vec> { +) -> Option<((u8, usize), &'a Vec)> { let mut best: Option<((u8, usize), &'a Vec)> = None; for (sidecar_name, body) in in_folder { let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else { @@ -645,7 +660,7 @@ fn find_sidecar<'a>( best = Some((rank, body)); } } - best.map(|(_, body)| body) + best } /// How well a sidecar's `.json`-stripped `stem` matches `file_name`, as a sort @@ -683,19 +698,44 @@ fn sidecar_rank(stem: &str, file_name: &str) -> Option<(u8, usize)> { } // 3 — truncation ate into the photo name itself, leaving only a prefix of // it. Every suffix boundary in the stem is tried, so this holds for a - // localized suffix too; the longest surviving prefix wins, because a folder - // with `IMG_1.jpg` and `IMG_12.jpg` must not let the shorter stem claim the - // longer photo. - let mut longest: Option = None; - for head in suffix_boundaries(stem) { - if !head.is_empty() - && file_name.starts_with(head) - && longest.is_none_or(|len| head.len() > len) - { - longest = Some(head.len()); + // localized suffix too; the longest surviving prefix wins. + // + // Only for a sidecar whose name is as long as Google lets one get: a name + // that short of the cap was not cut, so a prefix of it is the name of a + // different photo. Without this, `IMG_1.jpg`'s sidecar gave its name and + // date to an `IMG_12.jpg` that had none (`docs/BUGS.md` B225). + if stem.chars().count() + ".json".len() >= SIDECAR_NAME_CAP - 4 { + let mut longest: Option = None; + for head in suffix_boundaries(stem) { + if !head.is_empty() + && file_name.starts_with(head) + && longest.is_none_or(|len| head.len() > len) + { + longest = Some(head.len()); + } + } + if let Some(len) = longest { + return Some((3, usize::MAX - len)); } } - longest.map(|len| (3, usize::MAX - len)) + // 4 — an edited copy, `IMG_1-edited.jpg` (the word is localized), which + // Google exports without a sidecar of its own next to `IMG_1.jpg`'s. + suffix_boundaries(stem) + .filter_map(|head| head.rsplit_once('.')) + .any(|(original, extension)| is_edited_copy_of(file_name, original, extension)) + .then_some((EDITED_COPY, 0)) +} + +/// Whether `file_name` is `-.`, the name Google +/// gives an edited copy of `.`. +fn is_edited_copy_of(file_name: &str, original: &str, extension: &str) -> bool { + !original.is_empty() + && file_name + .strip_prefix(original) + .and_then(|rest| rest.strip_prefix('-')) + .and_then(|rest| rest.strip_suffix(extension)) + .and_then(|rest| rest.strip_suffix('.')) + .is_some_and(|word| !word.is_empty() && word.chars().all(char::is_alphabetic)) } /// The stem plus each of its prefixes ending before a `.`, longest first — the @@ -994,21 +1034,25 @@ mod tests { #[test] fn longest_prefix_wins_when_the_photo_name_itself_was_truncated() { + // Both sidecar names are at Google's cap, so both were cut into the + // photo name; the longer surviving prefix is the better claim. + let short = "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_E.json"; + let long = "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_EXPO.json"; let mut sidecars = HashMap::new(); sidecar( &mut sidecars, - "Takeout/Google Photos/Photos from 2019/IMG_1.json", + short, r#"{"photoTakenTime":{"timestamp":"1"}}"#, ); sidecar( &mut sidecars, - "Takeout/Google Photos/Photos from 2019/IMG_12.json", + long, r#"{"photoTakenTime":{"timestamp":"2"}}"#, ); let scan = assemble( vec![entry( 0, - "Takeout/Google Photos/Photos from 2019/IMG_12345.jpg", + "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_EXPOSURE.jpg", 10, )], &sidecars, @@ -1017,6 +1061,53 @@ mod tests { assert_eq!(scan.photos[0].capture_time, Some(2)); } + /// A short sidecar name was not cut, so a prefix of it names another photo: + /// `IMG_1.jpg`'s sidecar gave its title and date to `IMG_12.jpg` + /// (`docs/BUGS.md` B225). + #[test] + fn a_photo_without_a_sidecar_does_not_take_a_shorter_named_siblings() { + let mut sidecars = HashMap::new(); + sidecar( + &mut sidecars, + "Takeout/Google Photos/Photos from 2019/IMG_1.jpg.supplemental-metadata.json", + r#"{"title":"IMG_1.jpg","photoTakenTime":{"timestamp":"1"}}"#, + ); + let scan = assemble( + vec![ + entry(0, "Takeout/Google Photos/Photos from 2019/IMG_1.jpg", 10), + entry(0, "Takeout/Google Photos/Photos from 2019/IMG_12.jpg", 10), + ], + &sidecars, + ); + + assert_eq!(scan.photos[0].capture_time, Some(1)); + assert_eq!(scan.photos[1].name, "IMG_12.jpg"); + assert_eq!(scan.photos[1].capture_time, None); + } + + /// An edited copy has no sidecar of its own. It takes its original's date, + /// but keeps its own name rather than the original's title. + #[test] + fn an_edited_copy_takes_its_originals_date_and_keeps_its_name() { + let mut sidecars = HashMap::new(); + sidecar( + &mut sidecars, + "Takeout/Google Fotos/Urlaub/IMG_2.jpg.ergänzende-Metadaten.json", + r#"{"title":"Holiday.jpg","photoTakenTime":{"timestamp":"7"}}"#, + ); + let scan = assemble( + vec![ + entry(0, "Takeout/Google Fotos/Urlaub/IMG_2.jpg", 10), + entry(0, "Takeout/Google Fotos/Urlaub/IMG_2-bearbeitet.jpg", 10), + ], + &sidecars, + ); + + assert_eq!(scan.photos[0].name, "Holiday.jpg"); + assert_eq!(scan.photos[1].name, "IMG_2-bearbeitet.jpg"); + assert_eq!(scan.photos[1].capture_time, Some(7)); + } + #[test] fn sidecar_title_replaces_a_mangled_on_disk_name() { let mut sidecars = HashMap::new(); diff --git a/docs/BUGS.md b/docs/BUGS.md index 7fd0806..4543ddc 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B225](#b225--a-takeout-photo-without-a-sidecar-takes-a-siblings-name-and-date) | A Takeout photo without a sidecar takes a sibling's name and date | | [B223](#b223--a-damaged-config-file-is-replaced-with-defaults-on-the-next-settings-change) | A damaged config file is replaced with defaults on the next settings change | | [B224](#b224--fallocate-zero-ranges-are-undone-and-collapse-ranges-corrupt-the-file) | `fallocate` zero ranges are undone and collapse ranges corrupt the file | | [B222](#b222--a-full-disk-at-close-is-reported-to-nobody-and-fsync-on-a-read-only-file-fails) | A full disk at close is reported to nobody, and `fsync` on a read-only file fails | @@ -327,6 +328,35 @@ a live account since. Newest first. +### B225 — A Takeout photo without a sidecar takes a sibling's name and date + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 1.11). A scratch import through +`scan_archives` gave `IMG_12.jpg`, which had no sidecar, the name `IMG_1.jpg` and IMG_1's capture +time. It gave `IMG_2-edited.jpg` the title of its original. +**Where:** `sidecar_rank`, `find_sidecar` and `is_edited_copy_of` in +`crates/pdfs-core/src/takeout.rs` + +**Repro:** Import a Takeout folder holding `IMG_1.jpg` with its sidecar and `IMG_12.jpg` without +one. + +**Cause.** The last matching rule accepted any sidecar whose stem, cut at a `.`, was a prefix of +the photo's name. That rule exists for sidecar names Google cut at its 51-character cap, but it +also matched short names that had not been cut, so `IMG_1`'s sidecar claimed `IMG_12.jpg`. The +photo then went up under another photo's title and date, and two photos ended up with the same +name. Edited copies, which Google exports without a sidecar of their own, matched the same way +and took their original's title. + +**Fix.** The prefix rule applies only to a sidecar name that reaches the cap. An edited copy, +`-.` with the word in any language, is matched to its original's sidecar +by its own rule. It takes the capture time from that sidecar and keeps its own name. + +**Test:** `takeout::tests::a_photo_without_a_sidecar_does_not_take_a_shorter_named_siblings`, +`takeout::tests::an_edited_copy_takes_its_originals_date_and_keeps_its_name`, +`takeout::tests::longest_prefix_wins_when_the_photo_name_itself_was_truncated`. + +--- + ### B223 — A damaged config file is replaced with defaults on the next settings change **Status:** Fixed, not verified live