From ffdc30ed7579c82e5d47bfd467f924ad204b265c Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Fri, 4 Sep 2026 20:30:15 +1200 Subject: [PATCH 1/2] backup: say WHICH folder backups go to, iCloud Drive included "Saving to: Backups" cannot be told apart from the #52 internal fallback's own Documents/Backups, so a user who follows the screen's own tip and picks an iCloud Drive folder gets no confirmation it worked. folderLabel now names the place and two components of the tail: "iCloud Drive > Backups > NOOP". The iCloud rule gets a single definition shared with restoreListHealth, so the screen and the diagnostics dump cannot disagree about where the backups are. The trail slices AFTER the sync root rather than filtering the tilde component out: on macOS an iCloud path is ~/Library/Mobile Documents/ com~apple~CloudDocs/NOOP, so filtering alone leaves "Mobile Documents > NOOP". Picking the root itself yields no trail and the label is just "iCloud Drive", rather than a dangling separator. i18n_audit gains a narrow brand-phrase exemption. Its two-word floor already lets a one-word brand through; it cannot see a two-word one, so "iCloud Drive" repeated verbatim in German read as an untranslated echo when that is the correct rendering. Only strings that are ENTIRELY brand are exempt, so "Apple Health sync" stays gated. Chinese does translate the term and is unaffected, having never been an echo. --- Strand/Data/BackupSync.swift | 49 ++++++++++++++++++++++++-- Strand/Resources/Localizable.xcstrings | 4 ++- StrandTests/BackupSyncTests.swift | 41 +++++++++++++++++++++ Tools/i18n_audit.py | 20 +++++++++++ 4 files changed, 110 insertions(+), 4 deletions(-) diff --git a/Strand/Data/BackupSync.swift b/Strand/Data/BackupSync.swift index af7b118340..c04b8629bc 100644 --- a/Strand/Data/BackupSync.swift +++ b/Strand/Data/BackupSync.swift @@ -202,11 +202,53 @@ enum FolderBackup { return dir } + /// Whether a folder path sits in iCloud. The SINGLE definition of that rule — `restoreListHealth` + /// reports the same signal to diagnostics, and a screen that disagreed with the diagnostics dump + /// about where the backups are would make #278-style triage worse, not better. + /// + /// Ubiquity containers live under `Mobile Documents`, which covers both iCloud Drive proper + /// (`com~apple~CloudDocs`) and per-app containers. Pure and path-only, so it is testable without a + /// picker, a bookmark or a device. + static func isICloudPath(_ path: String) -> Bool { path.contains("Mobile Documents") } + + /// The tail of a folder path, for display: `"NOOP › 2026"`. + /// + /// The bare last component is not enough to identify a folder — a "Backups" folder in iCloud Drive + /// and the #52 internal fallback's `Documents/Backups` render identically, which is exactly the + /// confusion this line is meant to settle. The FULL path is not shown either: on iOS it is a long + /// sandbox UUID and on macOS it carries the user's home directory, neither of which helps. + /// + /// For an iCloud path everything up to and including the sync root (`com~apple~CloudDocs`, or a + /// per-app `iCloud~…` container) is dropped, since [folderLabel] already names iCloud Drive and the + /// raw tilde form means nothing to a reader. + static func folderTrail(path: String, maxComponents: Int = 2) -> String { + var parts = path.split(separator: "/").map(String.init).filter { !$0.isEmpty } + // Slice AFTER the sync root rather than filtering it out: on macOS an iCloud path is + // `~/Library/Mobile Documents/com~apple~CloudDocs/NOOP`, so merely dropping the tilde component + // leaves "Mobile Documents › NOOP" — plumbing the reader should never see. + if let i = parts.lastIndex(where: { + $0 == "com~apple~CloudDocs" || $0.hasPrefix("iCloud~") || $0 == "Mobile Documents" + }) { parts = Array(parts[(i + 1)...]) } + return parts.suffix(max(1, maxComponents)).joined(separator: " › ") + } + /// A short, human label for the chosen folder, or nil if none chosen. The internal fallback gets a /// friendly name instead of the raw "Backups" path component. + /// + /// Names iCloud explicitly (#52): the picker can already point anywhere, iCloud Drive included, so + /// the gap was never the choosing — it was that the result was unidentifiable afterwards. A user on + /// the internal fallback saw a bare "Backups" and could not tell their backups were on-device only. static func folderLabel() -> String? { if useInternalFolder { return String(localized: "NOOP (in Files)") } - return resolveFolder()?.lastPathComponent + guard let path = resolveFolder()?.path else { return nil } + let trail = folderTrail(path: path) + if isICloudPath(path) { + // Picking the iCloud Drive root itself leaves no trail; naming the place alone beats an + // empty separator dangling off the end. + return trail.isEmpty ? String(localized: "iCloud Drive") + : String(localized: "iCloud Drive › \(trail)") + } + return trail.isEmpty ? (path.split(separator: "/").last.map(String.init) ?? path) : trail } // MARK: - Security-scoped bookmark @@ -310,8 +352,9 @@ enum FolderBackup { let raw = (try? FileManager.default.contentsOfDirectory(atPath: folder.path))?.count ?? 0 // Report iCloud-vs-local (the actual #278 triage signal — undownloaded placeholders live in the // iCloud container) WITHOUT the folder's user-chosen name, which redactPii can't scrub and the - // iOS twin never exposes. The iCloud ubiquity container path contains "Mobile Documents". - let isICloud = folder.path.contains("Mobile Documents") + // iOS twin never exposes. Shares `isICloudPath` with the label the user sees, so the screen and + // this dump can never disagree about where the backups are. + let isICloud = isICloudPath(folder.path) return (isICloud, raw, listSnapshots().count) } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 0bb776273b..a06de1d29d 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -209686,7 +209686,9 @@ "Intermediate type": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Mischtyp"}},"es":{"stringUnit":{"state":"translated","value":"Tipo intermedio"}},"fr":{"stringUnit":{"state":"translated","value":"Type intermédiaire"}},"it":{"stringUnit":{"state":"translated","value":"Tipo intermedio"}},"pl":{"stringUnit":{"state":"translated","value":"Typ pośredni"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tipo intermédio"}},"ru":{"stringUnit":{"state":"translated","value":"Промежуточный тип"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"中间型"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"中間型"}}}}, "Evening type": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Abendtyp"}},"es":{"stringUnit":{"state":"translated","value":"Tipo vespertino"}},"fr":{"stringUnit":{"state":"translated","value":"Type vespéral"}},"it":{"stringUnit":{"state":"translated","value":"Tipo serale"}},"pl":{"stringUnit":{"state":"translated","value":"Typ wieczorny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tipo vespertino"}},"ru":{"stringUnit":{"state":"translated","value":"Вечерний тип"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"夜型人"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"夜型人"}}}}, "Body clock dial": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zifferblatt der inneren Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Esfera del reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"Cadran de l'horloge biologique"}},"it":{"stringUnit":{"state":"translated","value":"Quadrante dell'orologio biologico"}},"pl":{"stringUnit":{"state":"translated","value":"Tarcza zegara biologicznego"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Mostrador do relógio biológico"}},"ru":{"stringUnit":{"state":"translated","value":"Циферблат биологических часов"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"生物钟表盘"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"生理時鐘錶盤"}}}}, - "Your clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Deine innere Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"Votre horloge"}},"it":{"stringUnit":{"state":"translated","value":"Il tuo orologio"}},"pl":{"stringUnit":{"state":"translated","value":"Twój zegar"}},"pt-PT":{"stringUnit":{"state":"translated","value":"O seu relógio"}},"ru":{"stringUnit":{"state":"translated","value":"Ваши часы"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"你的生物钟"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"你的生理時鐘"}}}} + "Your clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Deine innere Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"Votre horloge"}},"it":{"stringUnit":{"state":"translated","value":"Il tuo orologio"}},"pl":{"stringUnit":{"state":"translated","value":"Twój zegar"}},"pt-PT":{"stringUnit":{"state":"translated","value":"O seu relógio"}},"ru":{"stringUnit":{"state":"translated","value":"Ваши часы"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"你的生物钟"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"你的生理時鐘"}}}}, + "iCloud Drive": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"es":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"fr":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"it":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"pl":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"pt-PT":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"ru":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"iCloud 云盘"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"iCloud 雲碟"}}}}, + "iCloud Drive › %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"es":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"fr":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"it":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"pl":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"ru":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"iCloud 云盘 › %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"iCloud 雲碟 › %@"}}}} }, "version": "1.0" } diff --git a/StrandTests/BackupSyncTests.swift b/StrandTests/BackupSyncTests.swift index c958433e06..ba1c8df69e 100644 --- a/StrandTests/BackupSyncTests.swift +++ b/StrandTests/BackupSyncTests.swift @@ -130,4 +130,45 @@ final class BackupSyncTests: XCTestCase { let out = BackupSync.restorablesNewestFirst([z, a]) { _ in sameMs } XCTAssertEqual(out.map(\.name), [a, z]) } + + // MARK: - Folder identity (#52): "Saving to:" has to say WHICH folder + + func testTrailSlicesAfterTheICloudSyncRoot() { + // macOS shape: merely filtering out the tilde component would leave "Mobile Documents › NOOP". + XCTAssertEqual( + FolderBackup.folderTrail(path: "/Users/x/Library/Mobile Documents/com~apple~CloudDocs/NOOP"), + "NOOP") + // iOS shape, two components deep. + XCTAssertEqual( + FolderBackup.folderTrail( + path: "/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs/Backups/NOOP"), + "Backups › NOOP") + // A per-app ubiquity container is a sync root too. + XCTAssertEqual( + FolderBackup.folderTrail(path: "/var/mobile/Library/Mobile Documents/iCloud~com~foo~bar/Documents/B"), + "Documents › B") + } + + func testTrailIsEmptyAtTheSyncRootItself() { + // Nothing below the root: `folderLabel` must fall back to naming the place, not emit a dangling "›". + XCTAssertEqual( + FolderBackup.folderTrail(path: "/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs"), + "") + } + + func testTrailKeepsTwoComponentsOutsideICloud() { + // The #52 internal fallback and an external cloud folder both stay identifiable. + XCTAssertEqual( + FolderBackup.folderTrail(path: "/var/mobile/Containers/Data/Application/9F3A-UUID/Documents/Backups"), + "Documents › Backups") + XCTAssertEqual( + FolderBackup.folderTrail(path: "/private/var/mobile/Containers/Shared/AppGroup/U/Dropbox/NoopBackups"), + "Dropbox › NoopBackups") + } + + func testICloudPathRuleMatchesTheDiagnosticsSignal() { + // One definition: the screen and `restoreListHealth`'s dump must never disagree. + XCTAssertTrue(FolderBackup.isICloudPath("/Users/x/Library/Mobile Documents/com~apple~CloudDocs/NOOP")) + XCTAssertFalse(FolderBackup.isICloudPath("/var/mobile/Containers/Data/Application/U/Documents/Backups")) + } } diff --git a/Tools/i18n_audit.py b/Tools/i18n_audit.py index 1efce7e058..a2f8826289 100644 --- a/Tools/i18n_audit.py +++ b/Tools/i18n_audit.py @@ -926,13 +926,33 @@ def extra_locale_allowance() -> dict[str, int]: FORMAT_SPECIFIER_PATTERN = re.compile(r"%(?:\d+\$)?[@#0\-+ ]*[\d.]*(?:ll|l|h)?[@dfsu]|%%") +# Multi-word product names that travel verbatim into Latin-script locales. The two-word floor below +# already lets a ONE-word brand through ("HRV", "Strava"); it cannot see a two-word one, so "iCloud +# Drive" repeated verbatim in German reads as an untranslated echo when it is the correct rendering. +# Only strings that are ENTIRELY brand are exempted (see `_is_pure_brand_phrase`), so "Apple Health +# sync" stays gated on its translatable word. CJK locales that DO translate these are unaffected — +# they differ from the source, so they were never counted as echoes in the first place. +BRAND_PHRASES = ("iCloud Drive",) + + +def _is_pure_brand_phrase(text: str) -> bool: + """Whether a string is nothing but brand names, placeholders and punctuation.""" + stripped = FORMAT_SPECIFIER_PATTERN.sub(" ", text) + for brand in BRAND_PHRASES: + stripped = stripped.replace(brand, " ") + return not re.search(r"[^\W\d_]{2,}", stripped, flags=re.UNICODE) + + def _has_translatable_words(text: str) -> bool: """Whether a string carries enough real words that an identical translation is suspicious. Strips format specifiers first: "%@ · n = %lld" / "%1$s: %2$s" are placeholders and punctuation with nothing to translate, so a locale repeating them verbatim is CORRECT, not a gap. Two words is the floor — one word is very often a term that legitimately travels ("HRV", "Yoga", a brand name). + A string that is entirely a multi-word brand is the same case one size up (see [BRAND_PHRASES]). """ + if _is_pure_brand_phrase(text): + return False stripped = FORMAT_SPECIFIER_PATTERN.sub(" ", text) return len(re.findall(r"[^\W\d_]{2,}", stripped, flags=re.UNICODE)) >= 2 From 3757f1725c6d9e215ff220a67db7456ce2eebb68 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Fri, 4 Sep 2026 20:35:16 +1200 Subject: [PATCH 2/2] i18n: translate "NOOP (in Files)", the other half of folderLabel Re-review of this branch found the sibling return of the function it changes had never entered the String Catalog, so the #52 fallback label shipped in English in all nine locales. The audit could not have caught it: SWIFT_CALL_START_PATTERN matches SwiftUI view constructors, so a String(localized:) in a model file is invisible to it. That blind spot is tracked in #844; only the single instance sitting in the function being edited is handled here, not the wider backlog behind it. --- Strand/Resources/Localizable.xcstrings | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index a06de1d29d..4c3978287f 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -209688,7 +209688,8 @@ "Body clock dial": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zifferblatt der inneren Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Esfera del reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"Cadran de l'horloge biologique"}},"it":{"stringUnit":{"state":"translated","value":"Quadrante dell'orologio biologico"}},"pl":{"stringUnit":{"state":"translated","value":"Tarcza zegara biologicznego"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Mostrador do relógio biológico"}},"ru":{"stringUnit":{"state":"translated","value":"Циферблат биологических часов"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"生物钟表盘"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"生理時鐘錶盤"}}}}, "Your clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Deine innere Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"Votre horloge"}},"it":{"stringUnit":{"state":"translated","value":"Il tuo orologio"}},"pl":{"stringUnit":{"state":"translated","value":"Twój zegar"}},"pt-PT":{"stringUnit":{"state":"translated","value":"O seu relógio"}},"ru":{"stringUnit":{"state":"translated","value":"Ваши часы"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"你的生物钟"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"你的生理時鐘"}}}}, "iCloud Drive": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"es":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"fr":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"it":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"pl":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"pt-PT":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"ru":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"iCloud 云盘"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"iCloud 雲碟"}}}}, - "iCloud Drive › %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"es":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"fr":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"it":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"pl":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"ru":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"iCloud 云盘 › %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"iCloud 雲碟 › %@"}}}} + "iCloud Drive › %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"es":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"fr":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"it":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"pl":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"ru":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"iCloud 云盘 › %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"iCloud 雲碟 › %@"}}}}, + "NOOP (in Files)": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"NOOP (in Dateien)"}},"es":{"stringUnit":{"state":"translated","value":"NOOP (en Archivos)"}},"fr":{"stringUnit":{"state":"translated","value":"NOOP (dans Fichiers)"}},"it":{"stringUnit":{"state":"translated","value":"NOOP (in File)"}},"pl":{"stringUnit":{"state":"translated","value":"NOOP (w Plikach)"}},"pt-PT":{"stringUnit":{"state":"translated","value":"NOOP (em Ficheiros)"}},"ru":{"stringUnit":{"state":"translated","value":"NOOP (в Файлах)"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"NOOP(在“文件”中)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"NOOP(在「檔案」中)"}}}} }, "version": "1.0" }