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..4c3978287f 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -209686,7 +209686,10 @@ "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 雲碟 › %@"}}}}, + "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" } 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