From c8bbfa39255133f939ab2f3c0071dc74b0f0a15a Mon Sep 17 00:00:00 2001 From: shimon <63890274+shimon-ai-dev@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:01:14 +0800 Subject: [PATCH 1/2] Adapt localization framework from DigUp PR #34 Adapt the localization infrastructure and Traditional Chinese resources from https://github.com/ARahim3/DigUp/pull/34, source commit ef69d2658fbdd5885626ddc68e5e7b12f7523fa7, to current upstream main. Credit for the original framework and Traditional Chinese work belongs to the author and contributors of PR #34. This commit is an adaptation, not a claim of original authorship. Keep the upstream product identity, update settings, and permissions. --- Package.swift | 5 +- Sources/DigUpApp/AddFolders.swift | 39 +- Sources/DigUpApp/App.swift | 39 +- Sources/DigUpApp/AppController.swift | 7 +- Sources/DigUpApp/AppModel.swift | 77 ++-- Sources/DigUpApp/AppSettings.swift | 5 +- Sources/DigUpApp/CodeEditors.swift | 3 +- Sources/DigUpApp/Components.swift | 27 +- Sources/DigUpApp/DebugHooks.swift | 5 +- Sources/DigUpApp/Engine.swift | 9 +- Sources/DigUpApp/Estimates.swift | 9 +- Sources/DigUpApp/FolderChoices.swift | 37 +- Sources/DigUpApp/Hotkey.swift | 13 +- Sources/DigUpApp/ModelDownload.swift | 31 +- Sources/DigUpApp/Moments.swift | 7 +- Sources/DigUpApp/Onboarding.swift | 143 +++--- Sources/DigUpApp/PowerPolicy.swift | 7 +- Sources/DigUpApp/Previews.swift | 17 +- Sources/DigUpApp/Results.swift | 61 +-- Sources/DigUpApp/SearchPanel.swift | 5 +- Sources/DigUpApp/SearchPanelView.swift | 83 ++-- Sources/DigUpApp/SearchWindow.swift | 79 ++-- Sources/DigUpApp/SettingsWindow.swift | 181 ++++---- Sources/DigUpApp/StatusMenu.swift | 27 +- Sources/DigUpLocalization/L10n.swift | 15 + .../Resources/en.lproj/InfoPlist.strings | 5 + .../Resources/en.lproj/Localizable.strings | 427 ++++++++++++++++++ .../Resources/zh-Hant.lproj/InfoPlist.strings | 5 + .../zh-Hant.lproj/Localizable.strings | 427 ++++++++++++++++++ .../LocalizationTests.swift | 84 ++++ build.sh | 8 + docs/localization.md | 35 ++ 32 files changed, 1474 insertions(+), 448 deletions(-) create mode 100644 Sources/DigUpLocalization/L10n.swift create mode 100644 Sources/DigUpLocalization/Resources/en.lproj/InfoPlist.strings create mode 100644 Sources/DigUpLocalization/Resources/en.lproj/Localizable.strings create mode 100644 Sources/DigUpLocalization/Resources/zh-Hant.lproj/InfoPlist.strings create mode 100644 Sources/DigUpLocalization/Resources/zh-Hant.lproj/Localizable.strings create mode 100644 Tests/DigUpLocalizationTests/LocalizationTests.swift create mode 100644 docs/localization.md diff --git a/Package.swift b/Package.swift index 82cb4214..560cb648 100644 --- a/Package.swift +++ b/Package.swift @@ -60,7 +60,7 @@ let appTargets: [Target] = [ // "DigUp": on a case-insensitive disk it would collide with the `digup` CLI binary.) .executableTarget( name: "DigUpApp", - dependencies: ["DigUpKit", .product(name: "Sparkle", package: "Sparkle")], + dependencies: ["DigUpKit", "DigUpLocalization", .product(name: "Sparkle", package: "Sparkle")], swiftSettings: [.defaultIsolation(MainActor.self)], linkerSettings: [.unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"])] ), @@ -73,6 +73,7 @@ let appTargets: [Target] = [] let package = Package( name: "DigUp", + defaultLocalization: "en", platforms: [.macOS(.v14)], products: [ .executable(name: "digup", targets: ["digup"]), @@ -82,6 +83,8 @@ let package = Package( targets: portableTargets + [ // The engine: crawler, extractors, index store, the embedder interface and worker client, search. No UI. // Platform/ holds what differs by OS (Apple's frameworks on macOS, the C libraries above elsewhere). + .target(name: "DigUpLocalization", resources: [.process("Resources")]), + .testTarget(name: "DigUpLocalizationTests", dependencies: ["DigUpLocalization"]), .target(name: "DigUpKit", dependencies: engineDependencies, linkerSettings: engineLink), // llama.cpp (scripts/build-llama.sh builds it into Vendor/llama) and EmbeddingGemma 2 on it. .systemLibrary(name: "CLlama", path: llamaModule), diff --git a/Sources/DigUpApp/AddFolders.swift b/Sources/DigUpApp/AddFolders.swift index 2130277d..02c9e377 100644 --- a/Sources/DigUpApp/AddFolders.swift +++ b/Sources/DigUpApp/AddFolders.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import DigUpKit import SwiftUI @@ -54,7 +55,7 @@ final class AddFoldersController { add: { [weak self] in self?.add() }) let sheet = NSWindow(contentViewController: NSHostingController(rootView: view)) sheet.styleMask = [.titled] - sheet.title = code ? "Code Folders" : "Add Folders" + sheet.title = code ? L10n.text("Code Folders") : L10n.text("Add Folders") self.sheet = sheet choices.startEstimates() log("add folders: \(choices.rows.count) to choose from") @@ -105,17 +106,17 @@ private struct AddFoldersView: View { VStack(spacing: 0) { VStack(alignment: .leading, spacing: 16) { if choices.code { - StepTitle(title: "Search your code", - subtitle: "Pick the folders that hold your projects; open one to pick among its repos. " - + "DigUp reads each once, then what changes. Code is searched on its own: type " - + "code: in the search panel.") + StepTitle(title: L10n.text("Search your code"), + subtitle: L10n.text("Pick the folders that hold your projects; open one to pick among its repos. ") + + L10n.text("DigUp reads each once, then what changes. Code is searched on its own: type ") + + L10n.text("code: in the search panel.")) } else { - StepTitle(title: "Add folders to search", - subtitle: "DigUp reads a new folder once, then keeps up with what changes in it. " - + "Open a folder to pick some of its subfolders.") + StepTitle(title: L10n.text("Add folders to search"), + subtitle: L10n.text("DigUp reads a new folder once, then keeps up with what changes in it. ") + + L10n.text("Open a folder to pick some of its subfolders.")) } ScrollView { - FolderList(choices: choices, addTitle: "Choose Another Folder…", add: pick) + FolderList(choices: choices, addTitle: L10n.text("Choose Another Folder…"), add: pick) if choices.code { CodeFolderNote().padding(.top, 8) } } .scrollBounceBehavior(.basedOnSize) @@ -130,9 +131,9 @@ private struct AddFoldersView: View { .foregroundStyle(.secondary) .lineLimit(1) Spacer(minLength: 8) - Button("Cancel", action: cancel) + Button(L10n.text("Cancel"), action: cancel) .keyboardShortcut(.cancelAction) - Button("Add", action: add) + Button(L10n.text("Add"), action: add) .keyboardShortcut(.defaultAction) .disabled(!choices.changesSomething) } @@ -144,15 +145,15 @@ private struct AddFoldersView: View { } private var summary: String { - guard choices.changesSomething else { return "Check the folders to add" } - guard choices.estimatesDone else { return "Looking at the folders…" } - let time = choices.totalSeconds < 1 ? "under a second" : "about \(roughDuration(choices.totalSeconds))" + guard choices.changesSomething else { return L10n.text("Check the folders to add") } + guard choices.estimatesDone else { return L10n.text("Looking at the folders…") } + let time = choices.totalSeconds < 1 ? L10n.text("under a second") : L10n.format("about %1$@", String(describing: roughDuration(choices.totalSeconds))) if choices.code { - return "Adds \(time) of indexing and \(roughBytes(choices.totalBytes))" + return L10n.format("Adds %1$@ of indexing and %2$@", String(describing: time), String(describing: roughBytes(choices.totalBytes))) } let later = choices.totalLaterSeconds - return "Adds \(time) of indexing" - + (later >= 30 ? ", and \(roughDuration(later)) more for long files" : "") + return L10n.format("Adds %1$@ of indexing", String(describing: time)) + + (later >= 30 ? L10n.format(", and %1$@ more for long files", String(describing: roughDuration(later))) : "") } } @@ -160,8 +161,8 @@ private struct AddFoldersView: View { struct CodeFolderNote: View { var body: some View { Label { - Text("Code search reads source files, notebooks and a repo's own docs. It skips what git ignores, vendored " - + "and generated code, data files, and anything that looks like a key or a password.") + Text(L10n.text("Code search reads source files, notebooks and a repo's own docs. It skips what git ignores, vendored ") + + L10n.text("and generated code, data files, and anything that looks like a key or a password.")) .fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: "info.circle") diff --git a/Sources/DigUpApp/App.swift b/Sources/DigUpApp/App.swift index 64ec3d1d..5553622d 100644 --- a/Sources/DigUpApp/App.swift +++ b/Sources/DigUpApp/App.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit @main @@ -63,31 +64,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let mainMenu = NSMenu() let appMenu = NSMenu() - appMenu.addItem(withTitle: "About DigUp", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + appMenu.addItem(withTitle: L10n.text("About DigUp"), action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") - appMenu.addItem(ActionMenuItem("Check for Updates…") { [weak app] in app?.updates.checkNow() }) + appMenu.addItem(ActionMenuItem(L10n.text("Check for Updates…")) { [weak app] in app?.updates.checkNow() }) appMenu.addItem(.separator()) - appMenu.addItem(ActionMenuItem("Settings…", key: ",") { [weak app] in app?.showSettings() }) + appMenu.addItem(ActionMenuItem(L10n.text("Settings…"), key: ",") { [weak app] in app?.showSettings() }) appMenu.addItem(.separator()) - appMenu.addItem(withTitle: "Hide DigUp", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") - appMenu.addItem(withTitle: "Quit DigUp", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + appMenu.addItem(withTitle: L10n.text("Hide DigUp"), action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + appMenu.addItem(withTitle: L10n.text("Quit DigUp"), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") - let fileMenu = NSMenu(title: "File") - fileMenu.addItem(ActionMenuItem("New Search", key: "n") { [weak app] in app?.showSearchWindow(query: "") }) - fileMenu.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + let fileMenu = NSMenu(title: L10n.text("File")) + fileMenu.addItem(ActionMenuItem(L10n.text("New Search"), key: "n") { [weak app] in app?.showSearchWindow(query: "") }) + fileMenu.addItem(withTitle: L10n.text("Close"), action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") - let editMenu = NSMenu(title: "Edit") - editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") - editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") + let editMenu = NSMenu(title: L10n.text("Edit")) + editMenu.addItem(withTitle: L10n.text("Undo"), action: Selector(("undo:")), keyEquivalent: "z") + editMenu.addItem(withTitle: L10n.text("Redo"), action: Selector(("redo:")), keyEquivalent: "Z") editMenu.addItem(.separator()) - editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") - editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") - editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") - editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") - - let windowMenu = NSMenu(title: "Window") - windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") - windowMenu.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "") + editMenu.addItem(withTitle: L10n.text("Cut"), action: #selector(NSText.cut(_:)), keyEquivalent: "x") + editMenu.addItem(withTitle: L10n.text("Copy"), action: #selector(NSText.copy(_:)), keyEquivalent: "c") + editMenu.addItem(withTitle: L10n.text("Paste"), action: #selector(NSText.paste(_:)), keyEquivalent: "v") + editMenu.addItem(withTitle: L10n.text("Select All"), action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + + let windowMenu = NSMenu(title: L10n.text("Window")) + windowMenu.addItem(withTitle: L10n.text("Minimize"), action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + windowMenu.addItem(withTitle: L10n.text("Close"), action: #selector(NSWindow.performClose(_:)), keyEquivalent: "") NSApp.windowsMenu = windowMenu for submenu in [appMenu, fileMenu, editMenu, windowMenu] { diff --git a/Sources/DigUpApp/AppController.swift b/Sources/DigUpApp/AppController.swift index f061d628..20956a5a 100644 --- a/Sources/DigUpApp/AppController.swift +++ b/Sources/DigUpApp/AppController.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import DigUpKit import ServiceManagement @@ -202,14 +203,14 @@ final class AppController { } /// Asks for folders with the standard open panel (as a sheet when there's a window). - func pickFolders(for window: NSWindow?, prompt: String = "Add", then done: @escaping ([URL]) -> Void) { + func pickFolders(for window: NSWindow?, prompt: String = L10n.text("Add"), then done: @escaping ([URL]) -> Void) { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = true panel.canCreateDirectories = false panel.prompt = prompt - panel.message = "Choose folders for DigUp to search." + panel.message = L10n.text("Choose folders for DigUp to search.") let finish = { (response: NSApplication.ModalResponse) in done(response == .OK ? panel.urls.map { AppSettings.folderURL($0.path) } : []) } @@ -234,7 +235,7 @@ final class AppController { /// Nil when `combo` is now the hotkey, else why it can't be. func setHotkey(_ combo: KeyCombo) -> String? { if let conflict = combo.systemConflict { return conflict } - guard hotkeys.register(combo) else { return "Another app is using \(combo.display)" } + guard hotkeys.register(combo) else { return L10n.format("Another app is using %1$@", String(describing: combo.display)) } AppSettings.save(hotkey: combo.spec) model.settings.hotkey = combo.spec model.hotkey = combo diff --git a/Sources/DigUpApp/AppModel.swift b/Sources/DigUpApp/AppModel.swift index 6f67d386..8a3f13cb 100644 --- a/Sources/DigUpApp/AppModel.swift +++ b/Sources/DigUpApp/AppModel.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import Observation @@ -23,41 +24,41 @@ final class AppModel { /// One line for the menu and the windows: what the index is doing. var statusLine: String { let status = self.status - guard hasFolders else { return "No folders chosen yet" } + guard hasFolders else { return L10n.text("No folders chosen yet") } switch status.activity { - case .starting: return "Starting…" + case .starting: return L10n.text("Starting…") case .failed(let problem): return "⚠︎ \(problem)" - case .syncing: return "Checking folders…" + case .syncing: return L10n.text("Checking folders…") case .indexing(let done, let total): let percent = total > 0 ? Int((Double(done) / Double(total) * 100).rounded(.down)) : 0 - return "Indexing \(percent)% · \(done.formatted()) of \(total.formatted())" + return L10n.format("Indexing %1$@%% · %2$@ of %3$@", String(describing: percent), String(describing: done.formatted()), String(describing: total.formatted())) case .finishing(let done, let total, let files): let percent = total > 0 ? Int((Double(done) / Double(total) * 100).rounded(.down)) : 0 - let which = files > 0 ? "\(files.formatted()) long \(files == 1 ? "file" : "files")" : "long files" - return "Reading the rest of \(which) · \(percent)%" + let which = files > 0 ? L10n.format("%1$@ long %2$@", String(describing: files.formatted()), String(describing: files == 1 ? "file" : "files")) : L10n.text("long files") + return L10n.format("Reading the rest of %1$@ · %2$@%%", String(describing: which), String(describing: percent)) case .idle: if let line = downloadLine { return line } if let code = status.code.indexing, code.total > 0 { let percent = Int((Double(code.done) / Double(code.total) * 100).rounded(.down)) - return "Indexing code \(percent)% · \(code.done.formatted()) of \(code.total.formatted())" + return L10n.format("Indexing code %1$@%% · %2$@ of %3$@", String(describing: percent), String(describing: code.done.formatted()), String(describing: code.total.formatted())) } - let waiting = "\(status.pending.formatted()) \(status.pending == 1 ? "file" : "files") waiting" - if status.pending > 0, status.paused { return "Paused · \(waiting)" } + let waiting = L10n.format("%1$@ %2$@ waiting", String(describing: status.pending.formatted()), String(describing: status.pending == 1 ? "file" : "files")) + if status.pending > 0, status.paused { return L10n.format("Paused · %1$@", String(describing: waiting)) } if let until = status.heldUntil, !status.paused { // Outside the set hours: what's known to wait, and when indexing starts again. - let when = "indexing from \(until.formatted(date: .omitted, time: .shortened))" + let when = L10n.format("indexing from %1$@", until.formatted(date: .omitted, time: .shortened)) if status.pending > 0 { return "\(waiting) · \(when)" } - return "\(status.searchable.formatted()) \(status.searchable == 1 ? "file" : "files") indexed · \(when)" + return L10n.format("%1$@ %2$@ indexed · %3$@", status.searchable.formatted(), status.searchable == 1 ? "file" : "files", when) } if status.pending > 0, let reason = status.waitingFor { return "\(waiting) · \(reason)" } - if status.pending > 0 { return "\(waiting) to be indexed" } + if status.pending > 0 { return L10n.format("%1$@ to be indexed", String(describing: waiting)) } if status.unfinished > 0 { - let rest = "\(status.unfinished.formatted()) long \(status.unfinished == 1 ? "file" : "files") to finish" - if status.paused { return "Paused · \(rest)" } + let rest = L10n.format("%1$@ long %2$@ to finish", String(describing: status.unfinished.formatted()), String(describing: status.unfinished == 1 ? "file" : "files")) + if status.paused { return L10n.format("Paused · %1$@", String(describing: rest)) } if let reason = status.waitingFor { return "\(rest) · \(reason)" } } - return "\(status.searchable.formatted()) \(status.searchable == 1 ? "file" : "files") indexed" - + (status.paused ? " · paused" : "") + return L10n.format("%1$@ %2$@ indexed", String(describing: status.searchable.formatted()), String(describing: status.searchable == 1 ? "file" : "files")) + + (status.paused ? L10n.text(" · paused") : "") } } @@ -65,11 +66,11 @@ final class AppModel { private var downloadLine: String? { switch download.phase { case .ready: nil - case .downloading: "Downloading the model · \(Int(download.fraction * 100))%" - case .verifying: "Checking the model download…" - case .retrying: "Model download: waiting for the network" - case .failed: "⚠︎ The model download stopped" - case .needed: status.pending > 0 ? "The model isn't downloaded yet" : nil + case .downloading: L10n.format("Downloading the model · %1$@%%", String(describing: Int(download.fraction * 100))) + case .verifying: L10n.text("Checking the model download…") + case .retrying: L10n.text("Model download: waiting for the network") + case .failed: L10n.text("⚠︎ The model download stopped") + case .needed: status.pending > 0 ? L10n.text("The model isn't downloaded yet") : nil } } @@ -89,34 +90,32 @@ final class AppModel { /// One line about the code index, for Settings → Code: "812 code files indexed", "Indexing code 42%…". var codeStatusLine: String { let code = status.code - guard hasCode else { return "Code search is off" } - if !download.isReady { return "Code is read once the model has downloaded" } + guard hasCode else { return L10n.text("Code search is off") } + if !download.isReady { return L10n.text("Code is read once the model has downloaded") } if let indexing = code.indexing, indexing.total > 0 { let percent = Int((Double(indexing.done) / Double(indexing.total) * 100).rounded(.down)) - return "Indexing code \(percent)% · \(indexing.done.formatted()) of \(indexing.total.formatted())" + return L10n.format("Indexing code %1$@%% · %2$@ of %3$@", String(describing: percent), String(describing: indexing.done.formatted()), String(describing: indexing.total.formatted())) } - let waiting = code.pending > 0 ? " · \(code.pending.formatted()) waiting" : "" - if code.pending > 0, status.paused { return "Paused · \(code.pending.formatted()) code files waiting" } - if code.pending > 0, let reason = status.waitingFor { return "\(code.pending.formatted()) code files waiting · \(reason)" } + let waiting = code.pending > 0 ? L10n.format(" · %1$@ waiting", String(describing: code.pending.formatted())) : "" + if code.pending > 0, status.paused { return L10n.format("Paused · %1$@ code files waiting", String(describing: code.pending.formatted())) } + if code.pending > 0, let reason = status.waitingFor { return L10n.format("%1$@ code files waiting · %2$@", String(describing: code.pending.formatted()), String(describing: reason)) } if code.pending > 0, let until = status.heldUntil { - return "\(code.pending.formatted()) code files waiting · indexing from " - + until.formatted(date: .omitted, time: .shortened) + return L10n.format("%1$@ code files waiting · indexing from %2$@", code.pending.formatted(), until.formatted(date: .omitted, time: .shortened)) } // Code is read once the other folders have nothing waiting (the helper's order). if code.pending > 0, status.pending > 0 { - let files = "\(code.pending.formatted()) code \(code.pending == 1 ? "file" : "files")" - return files + " waiting · read after your other files" + return L10n.format("%1$@ code %2$@ waiting · read after your other files", code.pending.formatted(), code.pending == 1 ? "file" : "files") } - return "\(code.searchable.formatted()) code \(code.searchable == 1 ? "file" : "files") indexed" + waiting + return L10n.format("%1$@ code %2$@ indexed", String(describing: code.searchable.formatted()), String(describing: code.searchable == 1 ? "file" : "files")) + waiting } /// A few words for the search bar while results may be incomplete: "Indexing 42%", "Downloading model · 42%". var activityBadge: String? { - if download.isActive { return "Downloading model · \(Int(download.fraction * 100))%" } + if download.isActive { return L10n.format("Downloading model · %1$@%%", String(describing: Int(download.fraction * 100))) } if case .indexing(let done, let total) = status.activity, total > 0 { - return "Indexing \(Int(Double(done) / Double(total) * 100))%" + return L10n.format("Indexing %1$@%%", String(describing: Int(Double(done) / Double(total) * 100))) } - if status.paused, status.pending > 0 { return "Indexing paused" } + if status.paused, status.pending > 0 { return L10n.text("Indexing paused") } return nil } @@ -124,19 +123,19 @@ final class AppModel { var meaningUnavailable: String? { guard !download.isReady else { return nil } if download.isActive { - return "Matching words only until the model has downloaded (\(Int(download.fraction * 100))%)" + return L10n.format("Matching words only until the model has downloaded (%1$@%%)", String(describing: Int(download.fraction * 100))) } - return "Matching words only: the model isn't downloaded" + return L10n.text("Matching words only: the model isn't downloaded") } /// Why results may be incomplete right now, or nil. var resultsCaveat: String? { if let meaningUnavailable { return meaningUnavailable } if case .indexing(let done, let total) = status.activity, total > 0 { - return "Still indexing (\(Int(Double(done) / Double(total) * 100))%): some files aren't in yet" + return L10n.format("Still indexing (%1$@%%): some files aren't in yet", String(describing: Int(Double(done) / Double(total) * 100))) } if status.pending > 0 { - return "\(status.pending.formatted()) \(status.pending == 1 ? "file isn't" : "files aren't") indexed yet" + return L10n.format("%1$@ %2$@ indexed yet", String(describing: status.pending.formatted()), String(describing: status.pending == 1 ? "file isn't" : "files aren't")) } return nil } diff --git a/Sources/DigUpApp/AppSettings.swift b/Sources/DigUpApp/AppSettings.swift index 62634830..fb74790c 100644 --- a/Sources/DigUpApp/AppSettings.swift +++ b/Sources/DigUpApp/AppSettings.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import DigUpKit import Foundation @@ -49,8 +50,8 @@ nonisolated struct AppSettings: Sendable { static let defaultHotkey = "cmd+shift+space" /// The battery switch, in onboarding and Settings. - static let batteryTitle = "Index on battery power" - static let batteryDetail = "When it's off, a folder's first pass waits for a charger. New files always go in right away." + static let batteryTitle = L10n.text("Index on battery power") + static let batteryDetail = L10n.text("When it's off, a folder's first pass waits for a charger. New files always go in right away.") /// Where settings are saved. Reads see launch arguments first either way. (UserDefaults is thread-safe.) nonisolated(unsafe) static let defaults: UserDefaults = { diff --git a/Sources/DigUpApp/CodeEditors.swift b/Sources/DigUpApp/CodeEditors.swift index 7139cc85..0e62346b 100644 --- a/Sources/DigUpApp/CodeEditors.swift +++ b/Sources/DigUpApp/CodeEditors.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import DigUpKit @@ -52,7 +53,7 @@ nonisolated struct CodeEditor: Identifiable, Hashable, Sendable { ] /// Each file opens in the app macOS opens it with, at its start. - static let defaultApp = CodeEditor(id: "", name: "The file's default app", method: .plain) + static let defaultApp = CodeEditor(id: "", name: L10n.text("The file's default app"), method: .plain) /// Where code results open now (`AppController.setCodeEditor`). @MainActor static var current = defaultApp diff --git a/Sources/DigUpApp/Components.swift b/Sources/DigUpApp/Components.swift index ea0ad122..c6f40a5d 100644 --- a/Sources/DigUpApp/Components.swift +++ b/Sources/DigUpApp/Components.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import DigUpKit import SwiftUI @@ -87,7 +88,7 @@ struct MomentBar: View { } } .frame(height: 4) - .accessibilityLabel("At \(ResultRow.clock(start)) of \(ResultRow.clock(duration))") + .accessibilityLabel(L10n.format("At %1$@ of %2$@", String(describing: ResultRow.clock(start)), String(describing: ResultRow.clock(duration)))) } } @@ -133,13 +134,13 @@ extension FileKind { func counted(_ count: Int) -> String { let one = count == 1 let noun = switch self { - case .screenshot: one ? "screenshot" : "screenshots" - case .image: one ? "image" : "images" - case .pdf: one ? "PDF" : "PDFs" - case .doc: one ? "document" : "documents" - case .audio: one ? "audio file" : "audio files" - case .video: one ? "video" : "videos" - case .code: one ? "code file" : "code files" + case .screenshot: one ? L10n.text("screenshot") : L10n.text("screenshots") + case .image: one ? L10n.text("image") : L10n.text("images") + case .pdf: one ? "PDF" : L10n.text("PDFs") + case .doc: one ? L10n.text("document") : L10n.text("documents") + case .audio: one ? L10n.text("audio file") : L10n.text("audio files") + case .video: one ? L10n.text("video") : L10n.text("videos") + case .code: one ? L10n.text("code file") : L10n.text("code files") } return "\(count.formatted()) \(noun)" } @@ -194,11 +195,11 @@ struct ShortcutRecorder: View { Button { setRecording(!recording) } label: { Group { if recording { - Text("Press the new shortcut…").foregroundStyle(.secondary) + Text(L10n.text("Press the new shortcut…")).foregroundStyle(.secondary) } else if let combo { Keycaps(caps: combo.caps, size: capSize) } else { - Text("Click to set a shortcut").foregroundStyle(.secondary) + Text(L10n.text("Click to set a shortcut")).foregroundStyle(.secondary) } } .font(.system(size: 13)) @@ -213,8 +214,8 @@ struct ShortcutRecorder: View { } .buttonStyle(.plain) .background(KeyCatcher(active: recording, onKey: handle, onResign: { setRecording(false) })) - .accessibilityLabel(recording ? "Recording a new shortcut" : "Shortcut \(combo?.display ?? "none"), click to change") - Text(problem ?? (recording ? "Esc cancels" : "Click to change")) + .accessibilityLabel(recording ? L10n.text("Recording a new shortcut") : L10n.format("Shortcut %1$@, click to change", String(describing: combo?.display ?? L10n.text("none")))) + Text(problem ?? (recording ? L10n.text("Esc cancels") : L10n.text("Click to change"))) .font(.system(size: 11)) .foregroundStyle(problem == nil ? AnyShapeStyle(.tertiary) : AnyShapeStyle(Color.red)) .multilineTextAlignment(.center) @@ -234,7 +235,7 @@ struct ShortcutRecorder: View { .numericPad]) if event.keyCode == 53, flags.isEmpty { return setRecording(false) } // Esc guard let combo = KeyCombo(keyCode: event.keyCode, flags: flags) else { - problem = "Use ⌘, ⌥ or ⌃ with a key" + problem = L10n.text("Use ⌘, ⌥ or ⌃ with a key") return } if let why = apply(combo) { diff --git a/Sources/DigUpApp/DebugHooks.swift b/Sources/DigUpApp/DebugHooks.swift index 45324d33..ec8a3f56 100644 --- a/Sources/DigUpApp/DebugHooks.swift +++ b/Sources/DigUpApp/DebugHooks.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import Quartz import ServiceManagement @@ -154,10 +155,10 @@ enum DebugHooks { let menu = app.statusMenu! DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { log("menu: \(menu.describeMenu())") - menu.performItem(titled: "Pause Indexing") + menu.performItem(titled: L10n.text("Pause Indexing")) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { log("menu after Pause: \(menu.describeMenu())") - menu.performItem(titled: "Resume Indexing") + menu.performItem(titled: L10n.text("Resume Indexing")) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { log("menu after Resume: \(menu.describeMenu())") } diff --git a/Sources/DigUpApp/Engine.swift b/Sources/DigUpApp/Engine.swift index a91121f6..517c0b00 100644 --- a/Sources/DigUpApp/Engine.swift +++ b/Sources/DigUpApp/Engine.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import DigUpKit import Foundation @@ -209,7 +210,7 @@ nonisolated final class Engine: @unchecked Sendable { func start() { searchQueue.async { [self] in guard openSearcher() else { - update { $0.activity = .failed("Can't open the index") } + update { $0.activity = .failed(L10n.text("Can't open the index")) } return } indexQueue.async { [self] in startHelper() } @@ -527,7 +528,7 @@ nonisolated final class Engine: @unchecked Sendable { try process.run() } catch { log("indexer: can't start \(process.executableURL?.path ?? "the helper"): \(error)") - update { $0.activity = .failed("Can't start indexing") } + update { $0.activity = .failed(L10n.text("Can't start indexing")) } return } helper = process @@ -636,7 +637,7 @@ nonisolated final class Engine: @unchecked Sendable { log("indexer: the model or settings changed, indexing everything again (was: \(event["from"] as? String ?? "?"))") case "error": log("indexer: \(event["message"] as? String ?? "error")") - if event["fatal"] as? Bool == true { update { $0.activity = .failed("Indexing stopped") } } + if event["fatal"] as? Bool == true { update { $0.activity = .failed(L10n.text("Indexing stopped")) } } default: break } @@ -678,7 +679,7 @@ nonisolated final class Engine: @unchecked Sendable { log("indexer: the model changed, the code index starts over") case "error": log("indexer: code: \(event["message"] as? String ?? "error")") - if event["fatal"] as? Bool == true { update { $0.activity = .failed("Indexing stopped") } } + if event["fatal"] as? Bool == true { update { $0.activity = .failed(L10n.text("Indexing stopped")) } } default: break } diff --git a/Sources/DigUpApp/Estimates.swift b/Sources/DigUpApp/Estimates.swift index d226707f..52dd4da1 100644 --- a/Sources/DigUpApp/Estimates.swift +++ b/Sources/DigUpApp/Estimates.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import DigUpKit import Foundation @@ -136,15 +137,15 @@ nonisolated final class LineBuffer: @unchecked Sendable { /// "≈ 8 s", "≈ 2 min", or "< 1 s" for a few short files. nonisolated func approximately(_ seconds: Double) -> String { - seconds < 0.5 ? "< 1 s" : "≈ \(roughDuration(seconds))" + seconds < 0.5 ? L10n.text("< 1 s") : "≈ \(roughDuration(seconds))" } /// "8 s", "2 min", "1.5 h": the precision an estimate deserves. nonisolated func roughDuration(_ seconds: Double) -> String { switch seconds { - case ..<90: "\(Int(seconds.rounded())) s" - case ..<5400: "\(Int((seconds / 60).rounded())) min" - default: String(format: "%.1f h", seconds / 3600) + case ..<90: L10n.format("%1$@ s", String(describing: Int(seconds.rounded()))) + case ..<5400: L10n.format("%1$@ min", String(describing: Int((seconds / 60).rounded()))) + default: String(format: L10n.text("%.1f h"), seconds / 3600) } } diff --git a/Sources/DigUpApp/FolderChoices.swift b/Sources/DigUpApp/FolderChoices.swift index 35f85ab6..1af8ac93 100644 --- a/Sources/DigUpApp/FolderChoices.swift +++ b/Sources/DigUpApp/FolderChoices.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import DigUpKit import Observation @@ -89,8 +90,8 @@ final class FolderChoices { expanded.insert(row.path) } else if selection.isSearched(path) { let by = selection.searchedAs(path) - note = "“\(folder.lastPathComponent)” is searched already" - + (by == nil || by == path ? "." : ", as part of “\((by! as NSString).lastPathComponent)”.") + note = L10n.format("“%1$@” is searched already", String(describing: folder.lastPathComponent)) + + (by == nil || by == path ? "." : L10n.format(", as part of “%1$@”.", String(describing: (by! as NSString).lastPathComponent))) } else { rows.append(folder) selection.setFolder(path, on: true) @@ -116,7 +117,7 @@ final class FolderChoices { selection.setSubfolder(path, on: false) deeper.append(path) } else { - note = "“\(folder.lastPathComponent)” isn't searched anyway." + note = L10n.format("“%1$@” isn't searched anyway.", String(describing: folder.lastPathComponent)) } } estimate(rows.filter { row in deeper.contains { FolderSelection.isInside($0, row.path) } }) @@ -267,10 +268,10 @@ private struct FolderRow: View { let row: URL @State private var showAll = false - static let laterHelp = "The first pass takes this long. Long PDFs and documents get their first pages in it, " - + "and the rest afterwards, once everything else is in." - static let codeHelp = "How long reading this code takes, once, and about how much room its index needs. After " - + "that, only what changes is read again." + static let laterHelp = L10n.text("The first pass takes this long. Long PDFs and documents get their first pages in it, ") + + L10n.text("and the rest afterwards, once everything else is in.") + static let codeHelp = L10n.text("How long reading this code takes, once, and about how much room its index needs. After ") + + L10n.text("that, only what changes is read again.") /// Subfolders shown before "Show N more". private static let shortList = 8 @@ -289,7 +290,7 @@ private struct FolderRow: View { SubfolderRow(choices: choices, subfolder: subfolder) } if !showAll, subfolders.count > Self.shortList { - Button("Show \(subfolders.count - Self.shortList) more") { showAll = true } + Button(L10n.format("Show %1$@ more", String(describing: subfolders.count - Self.shortList))) { showAll = true } .buttonStyle(.link) .font(.system(size: 11)) .frame(maxWidth: .infinity, alignment: .leading) @@ -326,9 +327,9 @@ private struct FolderRow: View { .buttonStyle(.plain) .opacity(canOpen ? 1 : 0) .disabled(!canOpen) - .help(open ? "Hide its subfolders" : "Choose among its subfolders") - .accessibilityLabel(open ? "Hide the subfolders of \(row.lastPathComponent)" - : "Show the subfolders of \(row.lastPathComponent)") + .help(open ? L10n.text("Hide its subfolders") : L10n.text("Choose among its subfolders")) + .accessibilityLabel(open ? L10n.format("Hide the subfolders of %1$@", String(describing: row.lastPathComponent)) + : L10n.format("Show the subfolders of %1$@", String(describing: row.lastPathComponent))) Checkbox(mark: mark, label: row.lastPathComponent) { choices.toggle(row) } Image(nsImage: NSWorkspace.shared.icon(forFile: row.path)) .resizable() @@ -336,15 +337,15 @@ private struct FolderRow: View { VStack(alignment: .leading, spacing: 3) { Text(row.lastPathComponent).font(.system(size: 13, weight: .semibold)) if let estimate, estimate.failed { - Text("Couldn't look inside it; it can still be searched.") + Text(L10n.text("Couldn't look inside it; it can still be searched.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } else if let estimate, !estimate.readable { HStack(spacing: 6) { - Text("macOS isn't letting DigUp read it.") + Text(L10n.text("macOS isn't letting DigUp read it.")) .font(.system(size: 11)) .foregroundStyle(.orange) - Button("Allow in Settings…", action: openFolderPrivacySettings) + Button(L10n.text("Allow in Settings…"), action: openFolderPrivacySettings) .buttonStyle(.link) .font(.system(size: 11)) } @@ -352,8 +353,8 @@ private struct FolderRow: View { KindCounts(counts: part.files) } else { Text(estimate == nil ? tildePath(row.path) - : estimate!.files.isEmpty ? (choices.code ? "No code here" : "Nothing to search here yet") - : "Nothing to search in the subfolders checked") + : estimate!.files.isEmpty ? (choices.code ? L10n.text("No code here") : L10n.text("Nothing to search here yet")) + : L10n.text("Nothing to search in the subfolders checked")) .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) @@ -371,7 +372,7 @@ private struct FolderRow: View { .font(.system(size: 10).monospacedDigit()) .foregroundStyle(.tertiary) } else if let later = part?.later, later >= 30 { - Text("+ \(roughDuration(later)) later") + Text(L10n.format("+ %1$@ later", String(describing: roughDuration(later)))) .font(.system(size: 10).monospacedDigit()) .foregroundStyle(.tertiary) } @@ -413,7 +414,7 @@ private struct SubfolderRow: View { Spacer(minLength: 8) Text(approximately(subfolder.seconds) + (choices.code ? " · \(roughBytes(subfolder.bytes))" - : subfolder.laterSeconds >= 30 ? " + \(roughDuration(subfolder.laterSeconds)) later" : "")) + : subfolder.laterSeconds >= 30 ? L10n.format(" + %1$@ later", String(describing: roughDuration(subfolder.laterSeconds))) : "")) .font(.system(size: 11, weight: .medium).monospacedDigit()) .foregroundStyle(on ? .secondary : .tertiary) .help(choices.code ? FolderRow.codeHelp : FolderRow.laterHelp) diff --git a/Sources/DigUpApp/Hotkey.swift b/Sources/DigUpApp/Hotkey.swift index 22f29fec..6749f1b0 100644 --- a/Sources/DigUpApp/Hotkey.swift +++ b/Sources/DigUpApp/Hotkey.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import Carbon.HIToolbox @@ -105,13 +106,13 @@ nonisolated struct KeyCombo: Equatable, Sendable { } guard taken else { return nil } switch (Int(keyCode), Int(carbonModifiers)) { - case (kVK_Space, cmdKey): return "Spotlight uses \(display)" - case (kVK_Space, cmdKey | optionKey): return "Finder's search window uses \(display)" - case (kVK_Space, controlKey), (kVK_Space, controlKey | optionKey): return "Switching input sources uses \(display)" - case (kVK_Space, controlKey | cmdKey): return "The emoji picker uses \(display)" + case (kVK_Space, cmdKey): return L10n.format("Spotlight uses %1$@", String(describing: display)) + case (kVK_Space, cmdKey | optionKey): return L10n.format("Finder's search window uses %1$@", String(describing: display)) + case (kVK_Space, controlKey), (kVK_Space, controlKey | optionKey): return L10n.format("Switching input sources uses %1$@", String(describing: display)) + case (kVK_Space, controlKey | cmdKey): return L10n.format("The emoji picker uses %1$@", String(describing: display)) case (kVK_ANSI_3, cmdKey | shiftKey), (kVK_ANSI_4, cmdKey | shiftKey), (kVK_ANSI_5, cmdKey | shiftKey): - return "Screenshots use \(display)" - default: return "macOS uses \(display) for one of its shortcuts" + return L10n.format("Screenshots use %1$@", String(describing: display)) + default: return L10n.format("macOS uses %1$@ for one of its shortcuts", String(describing: display)) } } } diff --git a/Sources/DigUpApp/ModelDownload.swift b/Sources/DigUpApp/ModelDownload.swift index ecc071c6..94db2d8e 100644 --- a/Sources/DigUpApp/ModelDownload.swift +++ b/Sources/DigUpApp/ModelDownload.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import DigUpKit import Foundation @@ -69,15 +70,15 @@ final class ModelDownload { /// "42% · about 1 min left", "Checking the download…", "Waiting for the network…" var progressText: String { switch phase { - case .needed: return "Not downloaded yet" + case .needed: return L10n.text("Not downloaded yet") case .downloading: let percent = "\(Int((fraction * 100).rounded(.down)))%" guard let left = secondsLeft else { return percent } - return percent + " · " + (left < 50 ? "less than a minute left" : "about \(roughDuration(left)) left") - case .verifying: return "Checking the download…" - case .retrying: return "Waiting for the network…" + return percent + " · " + (left < 50 ? L10n.text("less than a minute left") : L10n.format("about %1$@ left", String(describing: roughDuration(left)))) + case .verifying: return L10n.text("Checking the download…") + case .retrying: return L10n.text("Waiting for the network…") case .failed(let message): return message - case .ready: return "Ready" + case .ready: return L10n.text("Ready") } } @@ -108,13 +109,13 @@ final class ModelDownload { var folder = self.folder try folder.setResourceValues(values) } catch { - phase = .failed("Can't create \(tildePath(folder.path))") + phase = .failed(L10n.format("Can't create %1$@", String(describing: tildePath(folder.path)))) log("model: can't create \(folder.path): \(error)") return } let needed = ModelFiles.missing(in: folder).reduce(0) { $0 + $1.bytes } - partialBytes(of: file) + 200_000_000 if let free = freeSpace(), free < needed { - phase = .failed("Needs \(bytes(needed)) free on this disk") + phase = .failed(L10n.format("Needs %1$@ free on this disk", String(describing: bytes(needed)))) log("model: needs \(bytes(needed)) free, \(bytes(free)) is free") return } @@ -158,7 +159,7 @@ final class ModelDownload { }, waiting: { [weak self] in DispatchQueue.main.async { [weak self] in guard let self, attempt == current else { return } - phase = .retrying("Waiting for a network connection") + phase = .retrying(L10n.text("Waiting for a network connection")) log("model: waiting for a network connection") } }, done: { [weak self] outcome in @@ -214,12 +215,12 @@ final class ModelDownload { next() } else if chosen.remove(file.name) != nil { // Chosen by hand: say so, rather than download what the network may block. - phase = .failed("The chosen \(file.name) isn't the right file") + phase = .failed(L10n.format("The chosen %1$@ isn't the right file", file.name)) } else { failedChecks += 1 received = bytesOnDisk() if failedChecks >= 2 { - phase = .failed("The download was damaged twice. Try again later.") + phase = .failed(L10n.text("The download was damaged twice. Try again later.")) } else { next() } @@ -257,12 +258,12 @@ final class ModelDownload { /// afterwards downloads as usual. func use(_ urls: [URL]) { var picked: [(file: ModelFiles.File, url: URL)] = [] - var problem = "Those aren't the model's files" + var problem = L10n.text("Those aren't the model's files") for url in urls { let size = FileFetcher.size(of: url) guard let file = ModelFiles.all.first(where: { $0.bytes == size }) else { if let named = ModelFiles.all.first(where: { $0.name == url.lastPathComponent }) { - problem = "The chosen \(named.name) is \(bytes(size)), not \(bytes(named.bytes))" + problem = L10n.format("The chosen %1$@ is %2$@, not %3$@", named.name, bytes(size), bytes(named.bytes)) } continue } @@ -304,9 +305,8 @@ final class ModelDownload { panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = true - panel.prompt = "Use" - panel.message = "Choose the model files you downloaded: " - + ModelFiles.all.map(\.name).joined(separator: " and ") + "." + panel.prompt = L10n.text("Use") + panel.message = L10n.format("Choose the model files you downloaded: %1$@.", ModelFiles.all.map(\.name).joined(separator: L10n.text(" and "))) let finish = { [weak self] (response: NSApplication.ModalResponse) in guard response == .OK else { return } self?.use(panel.urls) @@ -355,4 +355,3 @@ final class ModelDownload { nonisolated func bytes(_ count: Int64) -> String { ByteCountFormatter.string(fromByteCount: count, countStyle: .file) } - diff --git a/Sources/DigUpApp/Moments.swift b/Sources/DigUpApp/Moments.swift index 16a149f3..d58d6fd9 100644 --- a/Sources/DigUpApp/Moments.swift +++ b/Sources/DigUpApp/Moments.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AVFoundation import AVKit import AppKit @@ -43,9 +44,9 @@ extension ResultRow { /// opens with your words in Preview's search, but Preview doesn't go to the page by itself. Code opens in the /// editor chosen for it: "Open in Zed". var openTitle: String { - if kind == .code, !CodeEditor.current.id.isEmpty { return "Open in \(CodeEditor.current.name)" } - if let moment, moment >= 1, Opener.jumpsToMoments(url) { return "Open at \(Self.clock(moment))" } - return "Open" + if kind == .code, !CodeEditor.current.id.isEmpty { return L10n.format("Open in %1$@", String(describing: CodeEditor.current.name)) } + if let moment, moment >= 1, Opener.jumpsToMoments(url) { return L10n.format("Open at %1$@", String(describing: Self.clock(moment))) } + return L10n.text("Open") } /// The line a code result opens at: where the name is defined or the query's words are, else where its stretch diff --git a/Sources/DigUpApp/Onboarding.swift b/Sources/DigUpApp/Onboarding.swift index 548eecb0..4a88550d 100644 --- a/Sources/DigUpApp/Onboarding.swift +++ b/Sources/DigUpApp/Onboarding.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import DigUpKit import Observation @@ -35,7 +36,7 @@ final class OnboardingController: NSObject, NSWindowDelegate { window.titlebarAppearsTransparent = true window.titleVisibility = .hidden window.isMovableByWindowBackground = true - window.title = "Welcome to DigUp" + window.title = L10n.text("Welcome to DigUp") window.isReleasedWhenClosed = false window.delegate = self state.close = { [weak self] in self?.close() } @@ -190,7 +191,7 @@ final class OnboardingState { } func skipFolders(from window: NSWindow?) { - app.pickFolders(for: window, prompt: "Skip") { [weak self] picked in + app.pickFolders(for: window, prompt: L10n.text("Skip")) { [weak self] picked in guard !picked.isEmpty else { return } self?.folders.skip(picked) } @@ -260,7 +261,7 @@ struct OnboardingView: View { .lineLimit(1) } if state.step != .welcome, state.step != .ready { - Button("Back") { state.back() } + Button(L10n.text("Back")) { state.back() } } Button(continueTitle) { state.next() } .keyboardShortcut(.defaultAction) @@ -273,26 +274,26 @@ struct OnboardingView: View { private var continueTitle: String { switch state.step { - case .welcome: "Get Started" - case .folders: "Continue" - case .code: "Continue" - case .shortcut: "Continue" - case .ready: "Done" + case .welcome: L10n.text("Get Started") + case .folders: L10n.text("Continue") + case .code: L10n.text("Continue") + case .shortcut: L10n.text("Continue") + case .ready: L10n.text("Done") } } private var codeSummary: String { - guard !state.codeFolders.roots.isEmpty else { return "Check the folders with your code" } - guard state.codeFolders.estimatesDone else { return "Looking at your code…" } + guard !state.codeFolders.roots.isEmpty else { return L10n.text("Check the folders with your code") } + guard state.codeFolders.estimatesDone else { return L10n.text("Looking at your code…") } let seconds = state.codeFolders.totalSeconds - return (seconds < 1 ? "Under a second" : "About \(roughDuration(seconds))") + return (seconds < 1 ? L10n.text("Under a second") : L10n.format("About %1$@", String(describing: roughDuration(seconds)))) + ", \(roughBytes(state.codeFolders.totalBytes))" } private var foldersSummary: String { - guard !state.folders.roots.isEmpty else { return "Choose at least one folder" } - guard state.folders.estimatesDone else { return "Looking at your folders…" } - return "First pass: about \(roughDuration(state.folders.totalSeconds))" + guard !state.folders.roots.isEmpty else { return L10n.text("Choose at least one folder") } + guard state.folders.estimatesDone else { return L10n.text("Looking at your folders…") } + return L10n.format("First pass: about %1$@", String(describing: roughDuration(state.folders.totalSeconds))) } } @@ -309,7 +310,7 @@ private struct StepDots: View { } } .accessibilityElement(children: .ignore) - .accessibilityLabel("Step \(current + 1) of \(count)") + .accessibilityLabel(L10n.format("Step %1$@ of %2$@", String(describing: current + 1), String(describing: count))) } } @@ -322,23 +323,23 @@ struct DownloadStatus: View { switch download.phase { case .ready: Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) - Text("Model ready") + Text(L10n.text("Model ready")) case .failed(let message): Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) Text(message).lineLimit(1) - Button("Try Again") { download.start() }.controlSize(.small) - Button("Use Files…") { download.chooseFiles(for: NSApp.keyWindow) } + Button(L10n.text("Try Again")) { download.start() }.controlSize(.small) + Button(L10n.text("Use Files…")) { download.chooseFiles(for: NSApp.keyWindow) } .controlSize(.small) - .help("Downloaded the model's two files somewhere else? Choose them here.") + .help(L10n.text("Downloaded the model's two files somewhere else? Choose them here.")) case .needed: Image(systemName: "arrow.down.circle").foregroundStyle(.secondary) - Text("Model not downloaded") - Button("Download") { download.start() }.controlSize(.small) + Text(L10n.text("Model not downloaded")) + Button(L10n.text("Download")) { download.start() }.controlSize(.small) default: ProgressView(value: download.fraction) .progressViewStyle(.circular) .controlSize(.small) - Text("Downloading the model · \(download.progressText)") + Text(L10n.format("Downloading the model · %1$@", String(describing: download.progressText))) .lineLimit(1) } } @@ -357,17 +358,17 @@ private struct WelcomeStep: View { Text("DigUp") .font(.system(size: 30, weight: .bold)) .padding(.top, 10) - Text("Describe it. Dig it up.") + Text(L10n.text("Describe it. Dig it up.")) .font(.system(size: 16)) .foregroundStyle(.secondary) .padding(.top, 2) VStack(alignment: .leading, spacing: 16) { - Feature(symbol: "camera.viewfinder", title: "Screenshots and photos", - example: "“that payment error”, “the zebra”, “whiteboard from Tuesday”") - Feature(symbol: "doc.text", title: "PDFs and documents", - example: "“the clause about pets in the lease”, “flight receipt”") - Feature(symbol: "waveform", title: "Moments inside audio and video", - example: "“where they talk about sleep”, “the part with the drums”") + Feature(symbol: "camera.viewfinder", title: L10n.text("Screenshots and photos"), + example: L10n.text("“that payment error”, “the zebra”, “whiteboard from Tuesday”")) + Feature(symbol: "doc.text", title: L10n.text("PDFs and documents"), + example: L10n.text("“the clause about pets in the lease”, “flight receipt”")) + Feature(symbol: "waveform", title: L10n.text("Moments inside audio and video"), + example: L10n.text("“where they talk about sleep”, “the part with the drums”")) } .padding(.top, 30) HStack(alignment: .top, spacing: 12) { @@ -375,8 +376,8 @@ private struct WelcomeStep: View { .font(.system(size: 18)) .foregroundStyle(.secondary) .frame(width: 30) - Text("Everything is indexed and searched on this Mac. DigUp downloads its AI model (865 MB) once; " - + "after that it works offline, and nothing you index ever leaves your Mac.") + Text(L10n.text("Everything is indexed and searched on this Mac. DigUp downloads its AI model (865 MB) once; ") + + L10n.text("after that it works offline, and nothing you index ever leaves your Mac.")) .font(.system(size: 12)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -431,12 +432,12 @@ private struct FoldersStep: View { var body: some View { VStack(alignment: .leading, spacing: 18) { - StepTitle(title: "What should DigUp search?", - subtitle: "Pick folders, or open one to pick some of its subfolders. You can change this " - + "anytime in Settings.") + StepTitle(title: L10n.text("What should DigUp search?"), + subtitle: L10n.text("Pick folders, or open one to pick some of its subfolders. You can change this ") + + L10n.text("anytime in Settings.")) ScrollView { VStack(spacing: 6) { - FolderList(choices: state.folders, addTitle: "Add Folder…") { + FolderList(choices: state.folders, addTitle: L10n.text("Add Folder…")) { state.addFolders(from: NSApp.keyWindow) } SkipChoices(state: state) @@ -445,8 +446,8 @@ private struct FoldersStep: View { .scrollBounceBehavior(.basedOnSize) HStack(alignment: .top, spacing: 8) { Image(systemName: "info.circle").foregroundStyle(.secondary) - Text("macOS will ask before DigUp can read folders like Desktop, Documents and Downloads. " - + "DigUp only reads files: it never changes, moves or uploads them.") + Text(L10n.text("macOS will ask before DigUp can read folders like Desktop, Documents and Downloads. ") + + L10n.text("DigUp only reads files: it never changes, moves or uploads them.")) .fixedSize(horizontal: false, vertical: true) } .font(.system(size: 11)) @@ -477,25 +478,25 @@ private struct SkipChoices: View { Image(systemName: "minus.circle.fill").foregroundStyle(.secondary) } .buttonStyle(.plain) - .help("Search this folder again") + .help(L10n.text("Search this folder again")) } .font(.system(size: 12)) } Button { state.skipFolders(from: NSApp.keyWindow) } label: { - Label("Skip a Folder…", systemImage: "plus") + Label(L10n.text("Skip a Folder…"), systemImage: "plus") } .controlSize(.small) HStack(spacing: 8) { - Text("File types") - TextField("Extensions", text: $state.skippedTypesText, prompt: Text("e.g. heic, txt")) + Text(L10n.text("File types")) + TextField(L10n.text("Extensions"), text: $state.skippedTypesText, prompt: Text(L10n.text("e.g. heic, txt"))) .labelsHidden() .controlSize(.small) .onSubmit { state.folders.setTypes(state.skippedTypesText) } } .font(.system(size: 12)) - Text("Code, keys, certificates, hidden folders, app bundles and caches are always skipped.") + Text(L10n.text("Code, keys, certificates, hidden folders, app bundles and caches are always skipped.")) .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -503,7 +504,7 @@ private struct SkipChoices: View { .padding(.top, 8) .padding(.leading, 4) } label: { - Text("Skip some folders or file types") + Text(L10n.text("Skip some folders or file types")) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) } @@ -522,17 +523,17 @@ private struct CodeStep: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - StepTitle(title: "Search your code too?", - subtitle: "DigUp can find code by what it does: type code: in the search panel, as in " - + "“code: retry with backoff”. It gets an index of its own and never shows up in other " - + "searches. You can turn it on later in Settings → Code.") - SwitchRow(title: "Search my code", detail: "Off unless you turn it on.", isOn: $state.searchCode) + StepTitle(title: L10n.text("Search your code too?"), + subtitle: L10n.text("DigUp can find code by what it does: type code: in the search panel, as in ") + + L10n.text("“code: retry with backoff”. It gets an index of its own and never shows up in other ") + + L10n.text("searches. You can turn it on later in Settings → Code.")) + SwitchRow(title: L10n.text("Search my code"), detail: L10n.text("Off unless you turn it on."), isOn: $state.searchCode) .padding(14) .background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color.primary.opacity(0.04))) if state.searchCode { ScrollView { VStack(spacing: 6) { - FolderList(choices: state.codeFolders, addTitle: "Add Folder…") { + FolderList(choices: state.codeFolders, addTitle: L10n.text("Add Folder…")) { state.addCodeFolders(from: NSApp.keyWindow) } CodeFolderNote().padding(.top, 4) @@ -540,8 +541,8 @@ private struct CodeStep: View { } .scrollBounceBehavior(.basedOnSize) HStack { - Text("Open code in") - Picker("Open code in", selection: Binding(get: { CodeEditor.current.id }, + Text(L10n.text("Open code in")) + Picker(L10n.text("Open code in"), selection: Binding(get: { CodeEditor.current.id }, set: { state.app.setCodeEditor($0) })) { ForEach(CodeEditor.installed) { editor in Text(editor.name).tag(editor.id) } Text(CodeEditor.defaultApp.name).tag(CodeEditor.defaultApp.id) @@ -563,17 +564,17 @@ private struct ShortcutStep: View { var body: some View { VStack(spacing: 0) { - StepTitle(title: "Open DigUp from anywhere", - subtitle: "Press the shortcut in any app, describe what you're looking for, and press Return.") + StepTitle(title: L10n.text("Open DigUp from anywhere"), + subtitle: L10n.text("Press the shortcut in any app, describe what you're looking for, and press Return.")) ShortcutRecorder(combo: state.model.hotkey, apply: { state.app.setHotkey($0) }, listening: { state.app.hotkeyRecording($0) }, capSize: 20) .padding(.top, 34) Group { if state.hotkeyWorked { - Label("That's it. It works from any app.", systemImage: "checkmark.circle.fill") + Label(L10n.text("That's it. It works from any app."), systemImage: "checkmark.circle.fill") .foregroundStyle(.green) } else if let hotkey = state.model.hotkey { - Text("Try it now: press \(hotkey.display).") + Text(L10n.format("Try it now: press %1$@.", String(describing: hotkey.display))) .foregroundStyle(.secondary) } } @@ -581,15 +582,15 @@ private struct ShortcutStep: View { .padding(.top, 16) .animation(.default, value: state.hotkeyWorked) VStack(alignment: .leading, spacing: 9) { - Text("IN THE SEARCH PANEL") + Text(L10n.text("IN THE SEARCH PANEL")) .font(.system(size: 10, weight: .semibold)) .tracking(0.6) .foregroundStyle(.secondary) - KeyTip(caps: ["↩"], text: "Open the file") - KeyTip(caps: ["⌘", "↩"], text: "Show it in Finder") - KeyTip(caps: ["Space"], text: "Quick Look, after arrowing to a result") - KeyTip(caps: ["⌘", "C"], text: "Copy the file, to paste anywhere") - KeyTip(caps: ["esc"], text: "Close") + KeyTip(caps: ["↩"], text: L10n.text("Open the file")) + KeyTip(caps: ["⌘", "↩"], text: L10n.text("Show it in Finder")) + KeyTip(caps: ["Space"], text: L10n.text("Quick Look, after arrowing to a result")) + KeyTip(caps: ["⌘", "C"], text: L10n.text("Copy the file, to paste anywhere")) + KeyTip(caps: ["esc"], text: L10n.text("Close")) } .padding(18) .frame(maxWidth: 340, alignment: .leading) @@ -621,7 +622,7 @@ private struct ReadyStep: View { .font(.system(size: 60)) .foregroundStyle(Brand.gradient) .padding(.top, 8) - Text("You're all set") + Text(L10n.text("You're all set")) .font(.system(size: 26, weight: .bold)) .padding(.top, 14) Text(progressNote) @@ -632,7 +633,7 @@ private struct ReadyStep: View { .frame(maxWidth: 470) .padding(.top, 8) VStack(alignment: .leading, spacing: 16) { - SwitchRow(title: "Open DigUp when you log in", detail: "So the shortcut always works.", + SwitchRow(title: L10n.text("Open DigUp when you log in"), detail: L10n.text("So the shortcut always works."), isOn: $state.launchAtLogin) Divider() SwitchRow(title: AppSettings.batteryTitle, detail: AppSettings.batteryDetail, @@ -644,7 +645,7 @@ private struct ReadyStep: View { .padding(.top, 28) HStack(spacing: 8) { Image(systemName: "sparkle.magnifyingglass") - Text("DigUp lives in the menu bar, which shows how indexing is going.") + Text(L10n.text("DigUp lives in the menu bar, which shows how indexing is going.")) } .font(.system(size: 12)) .foregroundStyle(.secondary) @@ -655,15 +656,15 @@ private struct ReadyStep: View { private var progressNote: String { let seconds = state.folders.totalSeconds, later = state.folders.totalLaterSeconds - let time = seconds > 0 ? " (about \(roughDuration(seconds)) on this Mac)" : "" - let rest = later >= 30 ? " Long PDFs and documents get their first pages in that time, and the rest " - + "afterwards (about \(roughDuration(later)) more)." : "" + let time = seconds > 0 ? L10n.format(" (about %1$@ on this Mac)", String(describing: roughDuration(seconds))) : "" + let rest = later >= 30 ? L10n.text(" Long PDFs and documents get their first pages in that time, and the rest ") + + L10n.format("afterwards (about %1$@ more).", String(describing: roughDuration(later))) : "" if state.download.isReady { - return "DigUp is reading your folders\(time), newest files first. You can search right away; " - + "results fill in as it goes." + rest + return L10n.format("DigUp is reading your folders%1$@, newest files first. You can search right away; ", String(describing: time)) + + L10n.text("results fill in as it goes.") + rest } - return "DigUp starts reading your folders\(time) once the model has downloaded " - + "(\(state.download.progressText)). Until then, search finds files by name." + rest + return L10n.format("DigUp starts reading your folders%1$@ once the model has downloaded ", String(describing: time)) + + L10n.format("(%1$@). Until then, search finds files by name.", String(describing: state.download.progressText)) + rest } } diff --git a/Sources/DigUpApp/PowerPolicy.swift b/Sources/DigUpApp/PowerPolicy.swift index 67c239ba..e05a3685 100644 --- a/Sources/DigUpApp/PowerPolicy.swift +++ b/Sources/DigUpApp/PowerPolicy.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import Foundation import IOKit.ps @@ -53,12 +54,12 @@ final class PowerPolicy { nonisolated static func backfillBlocker(indexOnBattery: Bool) -> String? { let info = ProcessInfo.processInfo - if info.isLowPowerModeEnabled { return "Low Power Mode" } + if info.isLowPowerModeEnabled { return L10n.text("Low Power Mode") } switch info.thermalState { - case .serious, .critical: return "Mac is warm" + case .serious, .critical: return L10n.text("Mac is warm") default: break } - if !indexOnBattery, !onACPower() { return "on battery" } + if !indexOnBattery, !onACPower() { return L10n.text("on battery") } return nil } diff --git a/Sources/DigUpApp/Previews.swift b/Sources/DigUpApp/Previews.swift index 247abeeb..608a00c7 100644 --- a/Sources/DigUpApp/Previews.swift +++ b/Sources/DigUpApp/Previews.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AVFoundation import AppKit import DigUpKit @@ -145,7 +146,7 @@ struct PreviewPane: View { .buttonStyle(.plain) .font(.system(size: 11)) .foregroundStyle(.secondary) - .help("Show in Finder") + .help(L10n.text("Show in Finder")) Text(row.details) .font(.system(size: 11)) .foregroundStyle(.secondary) @@ -170,7 +171,7 @@ struct PreviewActions: View { HStack(spacing: 8) { Button(row.openTitle, action: open) .keyboardShortcut(.defaultAction) - Button("Show in Finder", action: reveal) + Button(L10n.text("Show in Finder"), action: reveal) } .controlSize(controlSize) } @@ -288,7 +289,7 @@ struct AudioMoment: View { .foregroundStyle(Color.accentColor) } .buttonStyle(.plain) - .help(row.loc.map { "Play from \(ResultRow.clock($0))" } ?? "Play") + .help(row.loc.map { L10n.format("Play from %1$@", String(describing: ResultRow.clock($0))) } ?? L10n.text("Play")) } if let loc = row.loc { VStack(spacing: 5) { @@ -297,7 +298,7 @@ struct AudioMoment: View { playhead: playback.isCurrent(row) ? playback.time : nil) .frame(maxWidth: 260) } - Text(playback.isCurrent(row) ? "\(ResultRow.clock(playback.time)) – playing from " + Text(playback.isCurrent(row) ? L10n.format("%1$@ – playing from ", String(describing: ResultRow.clock(playback.time))) + ResultRow.clock(loc) : span(from: loc)) .font(.system(size: 11).monospacedDigit()) .foregroundStyle(.secondary) @@ -311,7 +312,7 @@ struct AudioMoment: View { /// "0:25 – 0:55 of 3:12" private func span(from start: Double) -> String { let end = row.locEnd ?? start + 30 - let total = row.duration.map { " of " + ResultRow.clock($0) } ?? "" + let total = row.duration.map { L10n.text(" of ") + ResultRow.clock($0) } ?? "" return ResultRow.clock(start) + " – " + ResultRow.clock(end) + total } } @@ -356,8 +357,8 @@ struct PlayButton: View { .overlay(Circle().strokeBorder(.white.opacity(0.7), lineWidth: 1.5)) } .buttonStyle(.plain) - .help(row.moment.map { "Play from \(ResultRow.clock($0))" } ?? "Play") - .accessibilityLabel(row.moment.map { "Play from \(ResultRow.clock($0))" } ?? "Play") + .help(row.moment.map { L10n.format("Play from %1$@", String(describing: ResultRow.clock($0))) } ?? L10n.text("Play")) + .accessibilityLabel(row.moment.map { L10n.format("Play from %1$@", String(describing: ResultRow.clock($0))) } ?? L10n.text("Play")) } } @@ -394,7 +395,7 @@ struct Passage: View { guard let part = row.loc.map(Int.init), let parts = row.info["chunks"].flatMap(Int.init), parts > 1 else { return nil } - return "Part \(part) of \(parts)" + return L10n.format("Part %1$@ of %2$@", String(describing: part), String(describing: parts)) } /// Up to ~900 characters from just before the first marked word, the words marked like a search in Preview. diff --git a/Sources/DigUpApp/Results.swift b/Sources/DigUpApp/Results.swift index 55c3c13d..97236f14 100644 --- a/Sources/DigUpApp/Results.swift +++ b/Sources/DigUpApp/Results.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import DigUpKit @@ -69,7 +70,7 @@ struct ResultRow: Identifiable, Equatable { var place: String? { guard let loc else { return nil } switch segment { - case .page, .pageImage: return "p. \(Int(loc))" + case .page, .pageImage: return L10n.format("p. %1$@", String(describing: Int(loc))) case .frame: return Self.clock(loc) case .audio: return kind == .video ? "♪ \(Self.clock(loc))" : Self.clock(loc) case .lines: return Int(loc) == Int(locEnd ?? loc) ? "L\(Int(loc))" : "L\(Int(loc))–\(Int(locEnd ?? loc))" @@ -83,8 +84,8 @@ struct ResultRow: Identifiable, Equatable { guard let loc else { return nil } let (first, last) = (Int(loc), Int(locEnd ?? loc)) switch segment { - case .lines: return first == last ? "line \(first)" : "lines \(first)–\(last)" - case .cells: return first == last ? "cell \(first)" : "cells \(first)–\(last)" + case .lines: return first == last ? L10n.format("line %1$@", String(describing: first)) : L10n.format("lines %1$@–%2$@", String(describing: first), String(describing: last)) + case .cells: return first == last ? L10n.format("cell %1$@", String(describing: first)) : L10n.format("cells %1$@–%2$@", String(describing: first), String(describing: last)) default: return nil } } @@ -96,19 +97,19 @@ struct ResultRow: Identifiable, Equatable { /// Why it's here, in a few words: "Matches the picture", "Matches the frame at 0:06". var reason: String { - guard matchedMeaning else { return matchedWords ? "Has your words" : "Matches its name" } + guard matchedMeaning else { return matchedWords ? L10n.text("Has your words") : L10n.text("Matches its name") } let place = loc.map(Self.clock) ?? "" - let page = loc.map { "page \(Int($0))" } ?? "a page" + let page = loc.map { L10n.format("page %1$@", String(describing: Int($0))) } ?? L10n.text("a page") switch segment { - case .image: return kind == .screenshot ? "Matches what's on screen" : "Matches the picture" - case .ocr: return "Matches the text in it" - case .frame: return "Matches the frame at \(place)" - case .audio: return kind == .video ? "Matches the sound at \(place)" : "Matches what's heard at \(place)" - case .page: return "Matches the text on \(page)" - case .pageImage: return "Matches \(page)" - case .chunk: return "Matches its text" - case .name: return "Matches its name" - case .lines, .cells: return "Matches \(span ?? "the code")" + case .image: return kind == .screenshot ? L10n.text("Matches what's on screen") : L10n.text("Matches the picture") + case .ocr: return L10n.text("Matches the text in it") + case .frame: return L10n.format("Matches the frame at %1$@", String(describing: place)) + case .audio: return kind == .video ? L10n.format("Matches the sound at %1$@", String(describing: place)) : L10n.format("Matches what's heard at %1$@", String(describing: place)) + case .page: return L10n.format("Matches the text on %1$@", String(describing: page)) + case .pageImage: return L10n.format("Matches %1$@", String(describing: page)) + case .chunk: return L10n.text("Matches its text") + case .name: return L10n.text("Matches its name") + case .lines, .cells: return L10n.format("Matches %1$@", String(describing: span ?? L10n.text("the code"))) } } @@ -118,9 +119,9 @@ struct ResultRow: Identifiable, Equatable { if kind == .code { var parts = [CodeExtractor.language(of: url.path)] if let cells = info["cells"].flatMap(Int.init) { - parts.append(cells == 1 ? "1 cell" : "\(cells) cells") + parts.append(cells == 1 ? L10n.text("1 cell") : L10n.format("%1$@ cells", String(describing: cells))) } else if let lines = info["lines"].flatMap(Int.init) { - parts.append(lines == 1 ? "1 line" : "\(lines.formatted()) lines") + parts.append(lines == 1 ? L10n.text("1 line") : L10n.format("%1$@ lines", String(describing: lines.formatted()))) } parts.append(modified.formatted(date: .abbreviated, time: .omitted)) if size > 0 { parts.append(bytes(size)) } @@ -131,9 +132,9 @@ struct ResultRow: Identifiable, Equatable { // A long file still being read (`Indexer.finishLongFiles`): what isn't read yet can't match. let readTo = info["read_to"].flatMap(Int.init) if let pages = info["pages"].flatMap(Int.init) { - parts.append((pages == 1 ? "1 page" : "\(pages) pages") + (readTo.map { ", \($0) read so far" } ?? "")) + parts.append((pages == 1 ? L10n.text("1 page") : L10n.format("%1$@ pages", String(describing: pages))) + (readTo.map { L10n.format(", %1$@ read so far", String(describing: $0)) } ?? "")) } else if readTo != nil { - parts.append("partly read so far") + parts.append(L10n.text("partly read so far")) } if let duration { parts.append(Self.clock(duration)) } if size > 0 { parts.append(bytes(size)) } @@ -169,12 +170,12 @@ enum ResultGroupKind: CaseIterable { var title: String { switch self { - case .screenshots: "Screenshots" - case .images: "Images" - case .documents: "Documents" - case .video: "Video" - case .audio: "Audio" - case .code: "Code" + case .screenshots: L10n.text("Screenshots") + case .images: L10n.text("Images") + case .documents: L10n.text("Documents") + case .video: L10n.text("Video") + case .audio: L10n.text("Audio") + case .code: L10n.text("Code") } } @@ -246,13 +247,13 @@ struct ResultSet: Equatable { extension FileKind { var title: String { switch self { - case .screenshot: "Screenshot" - case .image: "Image" + case .screenshot: L10n.text("Screenshot") + case .image: L10n.text("Image") case .pdf: "PDF" - case .doc: "Document" - case .audio: "Audio" - case .video: "Video" - case .code: "Code" + case .doc: L10n.text("Document") + case .audio: L10n.text("Audio") + case .video: L10n.text("Video") + case .code: L10n.text("Code") } } diff --git a/Sources/DigUpApp/SearchPanel.swift b/Sources/DigUpApp/SearchPanel.swift index ce158d3e..fec995ba 100644 --- a/Sources/DigUpApp/SearchPanel.swift +++ b/Sources/DigUpApp/SearchPanel.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import DigUpKit import Observation @@ -121,7 +122,7 @@ final class SearchPanelController { installKeyMonitor() if activity == nil { activity = ProcessInfo.processInfo.beginActivity(options: .userInitiatedAllowingIdleSystemSleep, - reason: "Search panel open") + reason: L10n.text("Search panel open")) } let shown = Date() DispatchQueue.main.async { @@ -374,7 +375,7 @@ final class SearchPanelController { pasteboard.clearContents() // As a file, like ⌘C in Finder: paste into a folder, a chat or a mail. pasteboard.writeObjects([row.url as NSURL]) - flash("Copied \(row.name)") + flash(L10n.format("Copied %1$@", String(describing: row.name))) } /// The search window, with this query (and only one kind, if given): for a bigger look. diff --git a/Sources/DigUpApp/SearchPanelView.swift b/Sources/DigUpApp/SearchPanelView.swift index 4d1d0d54..a81eb9ae 100644 --- a/Sources/DigUpApp/SearchPanelView.swift +++ b/Sources/DigUpApp/SearchPanelView.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import DigUpKit import SwiftUI @@ -30,12 +31,12 @@ struct SearchPanelView: View { .font(.system(size: controller.isCode ? 17 : 20, weight: .semibold)) .foregroundStyle(Brand.gradient) .frame(width: 24) - .help(controller.isCode ? "Searching your code" : "") + .help(controller.isCode ? L10n.text("Searching your code") : "") // With code search on, one example is code's: nothing else in the panel shows "code:". SearchField(text: $controller.query, fontSize: 22, placeholder: controller.model.hasCode - ? "Describe it: “zebra in a video”, “code: retry with backoff”…" - : "Describe it: “payment error screenshot”, “zebra in a video”…") { + ? L10n.text("Describe it: “zebra in a video”, “code: retry with backoff”…") + : L10n.text("Describe it: “payment error screenshot”, “zebra in a video”…")) { controller.field = $0 } if controller.phase == .words, controller.encoder == .starting { @@ -61,18 +62,18 @@ struct SearchPanelView: View { } else if controller.results.isEmpty { VStack(spacing: 8) { if controller.phase == .words { - Text("Searching…").foregroundStyle(.secondary) + Text(L10n.text("Searching…")).foregroundStyle(.secondary) } else if !controller.model.hasFolders { - Text("No folders chosen yet").font(.system(size: 15, weight: .semibold)) - Text("DigUp only searches the folders you pick.").foregroundStyle(.secondary) - Button("Choose Folders…") { controller.chooseFolders() } + Text(L10n.text("No folders chosen yet")).font(.system(size: 15, weight: .semibold)) + Text(L10n.text("DigUp only searches the folders you pick.")).foregroundStyle(.secondary) + Button(L10n.text("Choose Folders…")) { controller.chooseFolders() } .padding(.top, 4) } else if controller.isCode { - Text("No good matches in your code").font(.system(size: 15, weight: .semibold)) - Text("Try saying what the code does, or a name that's in it.").foregroundStyle(.secondary) + Text(L10n.text("No good matches in your code")).font(.system(size: 15, weight: .semibold)) + Text(L10n.text("Try saying what the code does, or a name that's in it.")).foregroundStyle(.secondary) } else { - Text("No good matches").font(.system(size: 15, weight: .semibold)) - Text("Try what you'd see or hear in it, or words you remember from it.") + Text(L10n.text("No good matches")).font(.system(size: 15, weight: .semibold)) + Text(L10n.text("Try what you'd see or hear in it, or words you remember from it.")) .foregroundStyle(.secondary) } } @@ -121,31 +122,31 @@ struct SearchPanelView: View { private var keysText: String { let row = controller.selectedRow if let row, row.kind == .code { - let line = row.segment == .lines ? " at line \(row.codeLine)" : "" - return "↩ \(row.openTitle)\(line) ⌘↩ Reveal ␣ Quick Look ⌘C Copy" + let line = row.segment == .lines ? L10n.format(" at line %1$@", String(describing: row.codeLine)) : "" + return L10n.format("↩ %1$@%2$@ ⌘↩ Reveal ␣ Quick Look ⌘C Copy", String(describing: row.openTitle), String(describing: line)) } - let place = row?.jump.map { " at \($0)" } ?? "" - return "↩ \(row?.openTitle ?? "Open") ⌘↩ Reveal ␣ Quick Look\(place) ⌘C Copy" + let place = row?.jump.map { L10n.format(" at %1$@", String(describing: $0)) } ?? "" + return L10n.format("↩ %1$@ ⌘↩ Reveal ␣ Quick Look%2$@ ⌘C Copy", String(describing: row?.openTitle ?? L10n.text("Open")), String(describing: place)) } private var statusText: String { if let note = controller.note { return note } switch controller.phase { case .words: - return controller.encoder == .starting ? "Matching words; meaning search is warming up…" : "Matching words…" + return controller.encoder == .starting ? L10n.text("Matching words; meaning search is warming up…") : L10n.text("Matching words…") case .wordsOnly: - return controller.model.meaningUnavailable ?? "Words only: meaning search isn't available" + return controller.model.meaningUnavailable ?? L10n.text("Words only: meaning search isn't available") case .codeHint, .codeOff: - return controller.phase == .codeOff ? "Code search is off" : "Searching your code" + return controller.phase == .codeOff ? L10n.text("Code search is off") : L10n.text("Searching your code") case .meaning, .empty: if controller.isCode, let indexing = controller.model.status.code.indexing, indexing.total > 0 { - return "Still reading code (\(Int(Double(indexing.done) / Double(indexing.total) * 100))%): some isn't in yet" + return L10n.format("Still reading code (%1$@%%): some isn't in yet", String(describing: Int(Double(indexing.done) / Double(indexing.total) * 100))) } if !controller.isCode, let caveat = controller.model.resultsCaveat { return caveat } let count = controller.results.strongRows.count let weaker = controller.results.weaker.count - return (count == 1 ? "1 result" : "\(count) results") + (controller.isCode ? " in your code" : "") - + (weaker > 0 ? " · \(weaker) weaker" : "") + return (count == 1 ? L10n.text("1 result") : L10n.format("%1$@ results", String(describing: count))) + (controller.isCode ? L10n.text(" in your code") : "") + + (weaker > 0 ? L10n.format(" · %1$@ weaker", String(describing: weaker)) : "") } } } @@ -173,14 +174,14 @@ private struct ResultList: View { Button { controller.openInWindow(group.kind) } label: { - Text("Show all \(group.total) \(group.kind.title.lowercased())…") + Text(L10n.format("Show all %1$@ %2$@…", String(describing: group.total), String(describing: group.kind.title.lowercased()))) .font(.system(size: 11.5, weight: .medium)) .foregroundStyle(Color.accentColor) .padding(.horizontal, 10) .padding(.vertical, 3) } .buttonStyle(.plain) - .help("Open them in the DigUp window (⌘O shows everything)") + .help(L10n.text("Open them in the DigUp window (⌘O shows everything)")) } } if !controller.results.weaker.isEmpty { @@ -284,7 +285,7 @@ private struct WeakerRow: View { Image(systemName: "chevron.right") .font(.system(size: 9.5, weight: .bold)) .rotationEffect(.degrees(expanded ? 90 : 0)) - Text(expanded ? "Weaker matches" : "\(count) weaker \(count == 1 ? "match" : "matches")") + Text(expanded ? L10n.text("Weaker matches") : L10n.format("%1$@ weaker %2$@", String(describing: count), String(describing: count == 1 ? "match" : "matches"))) .font(.system(size: 11.5, weight: .medium)) Spacer() } @@ -307,10 +308,10 @@ private struct WeakerExplainer: View { Image(systemName: "line.3.horizontal.decrease.circle") .font(.system(size: 30, weight: .light)) .foregroundStyle(.tertiary) - Text(expanded ? "Weaker matches" : "\(count) weaker \(count == 1 ? "match" : "matches")") + Text(expanded ? L10n.text("Weaker matches") : L10n.format("%1$@ weaker %2$@", String(describing: count), String(describing: count == 1 ? "match" : "matches"))) .font(.system(size: 14, weight: .semibold)) - Text("They match much less well than the results above. Press Return to " - + (expanded ? "fold them away." : "show them.")) + Text(L10n.text("They match much less well than the results above. Press Return to ") + + (expanded ? L10n.text("fold them away.") : L10n.text("show them."))) .font(.system(size: 12)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -376,7 +377,7 @@ struct SearchField: NSViewRepresentable { field.cell?.isScrollable = true field.cell?.wraps = false field.delegate = context.coordinator - field.setAccessibilityLabel("Search") + field.setAccessibilityLabel(L10n.text("Search")) onMake(field) return field } @@ -429,7 +430,7 @@ final class FocusReportingField: NSTextField { struct FileDragArea: NSViewRepresentable { let url: URL /// "Open at 0:06": what double-click and the menu's first item do. - var openTitle = "Open" + var openTitle = L10n.text("Open") let onClick: () -> Void let onOpen: () -> Void @@ -445,7 +446,7 @@ struct FileDragArea: NSViewRepresentable { final class FileDragView: NSView, NSDraggingSource { var url: URL? - var openTitle = "Open" + var openTitle = L10n.text("Open") var onClick: () -> Void = {} var onOpen: () -> Void = {} private var mouseDownEvent: NSEvent? @@ -487,13 +488,13 @@ final class FileDragView: NSView, NSDraggingSource { onClick() let menu = NSMenu() menu.addItem(ActionMenuItem(openTitle) { [onOpen] in onOpen() }) - menu.addItem(ActionMenuItem("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([url]) }) + menu.addItem(ActionMenuItem(L10n.text("Show in Finder")) { NSWorkspace.shared.activateFileViewerSelecting([url]) }) menu.addItem(.separator()) - menu.addItem(ActionMenuItem("Copy") { + menu.addItem(ActionMenuItem(L10n.text("Copy")) { NSPasteboard.general.clearContents() NSPasteboard.general.writeObjects([url as NSURL]) }) - menu.addItem(ActionMenuItem("Copy Path") { + menu.addItem(ActionMenuItem(L10n.text("Copy Path")) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(url.path, forType: .string) }) @@ -511,8 +512,8 @@ final class FileDragView: NSView, NSDraggingSource { private struct CodeHint: View { let controller: SearchPanelController - static let examples = ["code: retry a request with backoff", "code: where the settings are saved", - "code: parse the command line options"] + static let examples = [L10n.text("code: retry a request with backoff"), L10n.text("code: where the settings are saved"), + L10n.text("code: parse the command line options")] var body: some View { VStack(spacing: 10) { @@ -520,17 +521,17 @@ private struct CodeHint: View { .font(.system(size: 26, weight: .medium)) .foregroundStyle(Brand.gradient) if controller.phase == .codeOff { - Text("Code search is off").font(.system(size: 15, weight: .semibold)) - Text("DigUp can find code by what it does, in the folders you pick. Code gets an index of its own " - + "and never shows up in other searches.") + Text(L10n.text("Code search is off")).font(.system(size: 15, weight: .semibold)) + Text(L10n.text("DigUp can find code by what it does, in the folders you pick. Code gets an index of its own ") + + L10n.text("and never shows up in other searches.")) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 420) - Button("Choose Code Folders…") { controller.setUpCodeSearch() } + Button(L10n.text("Choose Code Folders…")) { controller.setUpCodeSearch() } .padding(.top, 4) } else { - Text("Search your code").font(.system(size: 15, weight: .semibold)) - Text("Say what the code does, or type a name that's in it.").foregroundStyle(.secondary) + Text(L10n.text("Search your code")).font(.system(size: 15, weight: .semibold)) + Text(L10n.text("Say what the code does, or type a name that's in it.")).foregroundStyle(.secondary) VStack(spacing: 6) { ForEach(Self.examples, id: \.self) { example in Button { controller.tryExample(example) } label: { diff --git a/Sources/DigUpApp/SearchWindow.swift b/Sources/DigUpApp/SearchWindow.swift index 4fc54cc6..9821d114 100644 --- a/Sources/DigUpApp/SearchWindow.swift +++ b/Sources/DigUpApp/SearchWindow.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import DigUpKit import Observation @@ -380,7 +381,7 @@ struct SearchWindowView: View { Image(systemName: "xmark.circle.fill").foregroundStyle(.tertiary) } .buttonStyle(.plain) - .help("Clear") + .help(L10n.text("Clear")) } } .padding(.horizontal, 12) @@ -398,7 +399,7 @@ struct SearchWindowView: View { .foregroundStyle(search.showsPreview ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.secondary)) } .buttonStyle(.plain) - .help(search.showsPreview ? "Hide Preview" : "Show Preview") + .help(search.showsPreview ? L10n.text("Hide Preview") : L10n.text("Show Preview")) Button { app.showSettings() } label: { @@ -407,7 +408,7 @@ struct SearchWindowView: View { .foregroundStyle(.secondary) } .buttonStyle(.plain) - .help("Settings (⌘,)") + .help(L10n.text("Settings (⌘,)")) } .padding(.leading, 78) // beside the window buttons .frame(height: 52) @@ -421,12 +422,12 @@ struct SearchWindowView: View { if !model.hasFolders { VStack(spacing: 0) { BrandMark() - Text("Choose folders to search").font(.system(size: 20, weight: .bold)).padding(.top, 18) - Text("DigUp only looks where you tell it to.") + Text(L10n.text("Choose folders to search")).font(.system(size: 20, weight: .bold)).padding(.top, 18) + Text(L10n.text("DigUp only looks where you tell it to.")) .font(.system(size: 13)) .foregroundStyle(.secondary) .padding(.top, 6) - Button("Choose Folders…") { app.chooseFolders() } + Button(L10n.text("Choose Folders…")) { app.chooseFolders() } .controlSize(.large) .keyboardShortcut(.defaultAction) .padding(.top, 18) @@ -441,9 +442,9 @@ struct SearchWindowView: View { ProgressView().controlSize(.small).frame(maxWidth: .infinity, maxHeight: .infinity) } else if search.results.isEmpty { EmptyState(symbol: search.isCode ? FileKind.code.symbol : "magnifyingglass", - title: "No good matches for “\(search.query.trimmingCharacters(in: .whitespaces))”", - message: search.isCode ? "Try saying what the code does, or a name that's in it." - : "Try describing what's in it: what you'd see or hear, or words you remember.") + title: L10n.format("No good matches for “%1$@”", String(describing: search.query.trimmingCharacters(in: .whitespaces))), + message: search.isCode ? L10n.text("Try saying what the code does, or a name that's in it.") + : L10n.text("Try describing what's in it: what you'd see or hear, or words you remember.")) } else if search.isCode { CodeResultsList(search: search) } else { @@ -460,7 +461,7 @@ struct SearchWindowView: View { Button(model.statusLine) { app.showSettings(.folders) } .buttonStyle(.plain) .lineLimit(1) - .help("Folders and indexing") + .help(L10n.text("Folders and indexing")) Spacer() if search.hasResults, !search.query.isEmpty { Text(model.resultsCaveat ?? resultCount).lineLimit(1) @@ -469,11 +470,11 @@ struct SearchWindowView: View { Button { app.updates.checkNow() } label: { - Label("DigUp \(version) is available", systemImage: "arrow.down.circle.fill") + Label(L10n.format("DigUp %1$@ is available", String(describing: version)), systemImage: "arrow.down.circle.fill") .foregroundStyle(Brand.ember) } .buttonStyle(.plain) - .help("See what's new and update") + .help(L10n.text("See what's new and update")) } } .font(.system(size: 11)) @@ -484,15 +485,15 @@ struct SearchWindowView: View { /// Examples of what to type: code's with the Code chip on, and one of them with code search on (as the panel's). private var placeholder: String { - if search.filter == .code { return "Describe the code: “retry a request with backoff”, a name in it…" } - return model.hasCode ? "Describe it: “zebra in a video”, “code: retry with backoff”…" - : "Describe it: “receipt from the hardware store”, “zebra in a video”…" + if search.filter == .code { return L10n.text("Describe the code: “retry a request with backoff”, a name in it…") } + return model.hasCode ? L10n.text("Describe it: “zebra in a video”, “code: retry with backoff”…") + : L10n.text("Describe it: “receipt from the hardware store”, “zebra in a video”…") } private var resultCount: String { let count = search.results.ranked.count - return (count == 1 ? "1 result" : "\(count) results") - + (search.results.weaker.isEmpty ? "" : " · \(search.results.weaker.count) weaker") + return (count == 1 ? L10n.text("1 result") : L10n.format("%1$@ results", String(describing: count))) + + (search.results.weaker.isEmpty ? "" : L10n.format(" · %1$@ weaker", String(describing: search.results.weaker.count))) } } @@ -504,7 +505,7 @@ private struct KindFilters: View { var body: some View { HStack(spacing: 6) { - chip(title: "All", symbol: nil, count: nil, selected: search.filter == nil && !search.isCode) { + chip(title: L10n.text("All"), symbol: nil, count: nil, selected: search.filter == nil && !search.isCode) { search.filter = nil } ForEach(ResultGroupKind.files, id: \.self) { kind in @@ -515,7 +516,7 @@ private struct KindFilters: View { } if showsCode { Divider().frame(height: 16).padding(.horizontal, 4) - chip(title: "Code", symbol: FileKind.code.symbol, count: search.isCode ? search.counts[.code] : nil, + chip(title: L10n.text("Code"), symbol: FileKind.code.symbol, count: search.isCode ? search.counts[.code] : nil, selected: search.isCode, shortcut: 7) { search.filter = search.filter == .code ? nil : .code } @@ -723,7 +724,7 @@ private struct CodeResultRow: View { .fill(selected ? Color.accentColor.opacity(0.22) : Color.clear)) .contentShape(Rectangle()) .accessibilityElement(children: .ignore) - .accessibilityLabel("Code, \(row.name), \(row.reason)") + .accessibilityLabel(L10n.format("Code, %1$@, %2$@", String(describing: row.name), String(describing: row.reason))) .accessibilityAddTraits(selected ? .isSelected : []) } } @@ -740,17 +741,17 @@ private struct CodeOffOrHint: View { .font(.system(size: 34, weight: .medium)) .foregroundStyle(Brand.gradient) if phase == .codeOff { - Text("Code search is off").font(.system(size: 17, weight: .semibold)) - Text("DigUp can find code by what it does, in the folders you pick. Code gets an index of its own and " - + "never shows up in other searches.") + Text(L10n.text("Code search is off")).font(.system(size: 17, weight: .semibold)) + Text(L10n.text("DigUp can find code by what it does, in the folders you pick. Code gets an index of its own and ") + + L10n.text("never shows up in other searches.")) .font(.system(size: 13)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 420) - Button("Choose Code Folders…", action: chooseFolders).padding(.top, 6) + Button(L10n.text("Choose Code Folders…"), action: chooseFolders).padding(.top, 6) } else { - Text("Search your code").font(.system(size: 17, weight: .semibold)) - Text("Say what the code does, or type a name that's in it.") + Text(L10n.text("Search your code")).font(.system(size: 17, weight: .semibold)) + Text(L10n.text("Say what the code does, or type a name that's in it.")) .font(.system(size: 13)) .foregroundStyle(.secondary) } @@ -796,7 +797,7 @@ struct WeakerToggle: View { Image(systemName: "chevron.right") .font(.system(size: 10, weight: .semibold)) .rotationEffect(.degrees(expanded ? 90 : 0)) - Text(expanded ? "Weaker matches" : "\(count) weaker \(count == 1 ? "match" : "matches")") + Text(expanded ? L10n.text("Weaker matches") : L10n.format("%1$@ weaker %2$@", String(describing: count), String(describing: count == 1 ? "match" : "matches"))) .font(.system(size: 12, weight: .medium)) } .foregroundStyle(.secondary) @@ -842,25 +843,25 @@ private struct SearchHome: View { @State private var kinds: [FileKind: Int] = [:] private static let examples: [(text: String, kind: ResultGroupKind)] = [ - ("a screenshot of an error message", .screenshots), ("receipt", .documents), ("whiteboard notes", .images), - ("someone laughing", .audio), ("a dog on the beach", .video), ("slides about the budget", .documents), + (L10n.text("a screenshot of an error message"), .screenshots), ("receipt", .documents), (L10n.text("whiteboard notes"), .images), + (L10n.text("someone laughing"), .audio), (L10n.text("a dog on the beach"), .video), (L10n.text("slides about the budget"), .documents), ] var body: some View { VStack(spacing: 0) { BrandMark() - Text("Describe it. Dig it up.") + Text(L10n.text("Describe it. Dig it up.")) .font(.system(size: 24, weight: .bold)) .padding(.top, 18) - Text("Type what you remember in the search field above: what's in it, what it looks or sounds like, or " - + "words from it.") + Text(L10n.text("Type what you remember in the search field above: what's in it, what it looks or sounds like, or ") + + L10n.text("words from it.")) .font(.system(size: 13)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 440) .padding(.top, 6) - Text("TRY") + Text(L10n.text("TRY")) .font(.system(size: 10, weight: .semibold)) .tracking(0.8) .foregroundStyle(.tertiary) @@ -876,15 +877,15 @@ private struct SearchHome: View { if !kinds.isEmpty { HStack(spacing: 8) { KindCounts(counts: kinds) - Text("in \(folders)") + Text(L10n.format("in %1$@", String(describing: folders))) } } else if model.status.searchable > 0 { - Text("\(model.status.searchable.formatted()) files to search in \(folders)") + Text(L10n.format("%1$@ files to search in %2$@", String(describing: model.status.searchable.formatted()), String(describing: folders))) } if let hotkey = model.hotkey { HStack(spacing: 6) { Keycaps(caps: hotkey.caps, size: 10) - Text("opens DigUp from any app") + Text(L10n.text("opens DigUp from any app")) } } } @@ -905,12 +906,12 @@ private struct SearchHome: View { /// With code search on, one of code's too. private var examples: [(text: String, kind: ResultGroupKind)] { - Self.examples + (model.hasCode ? [("code: where the settings are saved", .code)] : []) + Self.examples + (model.hasCode ? [(L10n.text("code: where the settings are saved"), .code)] : []) } private var folders: String { let names = model.settings.roots.map(\.lastPathComponent) - return names.count <= 3 ? names.formatted(.list(type: .and)) : "\(names.count) folders" + return names.count <= 3 ? names.formatted(.list(type: .and)) : L10n.format("%1$@ folders", String(describing: names.count)) } } @@ -954,7 +955,7 @@ private struct ExampleChip: View { .contentShape(Capsule()) } .buttonStyle(.plain) - .help("Search for “\(text)”") + .help(L10n.format("Search for “%1$@”", String(describing: text))) .onHover { inside in hovering = inside if inside { NSCursor.pointingHand.push() } else { NSCursor.pop() } diff --git a/Sources/DigUpApp/SettingsWindow.swift b/Sources/DigUpApp/SettingsWindow.swift index f56d40fd..d0d146b5 100644 --- a/Sources/DigUpApp/SettingsWindow.swift +++ b/Sources/DigUpApp/SettingsWindow.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import DigUpKit import Observation @@ -8,10 +9,10 @@ enum SettingsTab: String, CaseIterable { var title: String { switch self { - case .general: "General" - case .folders: "Folders" - case .code: "Code" - case .storage: "Storage" + case .general: L10n.text("General") + case .folders: L10n.text("Folders") + case .code: L10n.text("Code") + case .storage: L10n.text("Storage") } } @@ -210,8 +211,8 @@ private struct GeneralSettings: View { listening: { state.app.hotkeyRecording($0) }, capSize: 13) } label: { VStack(alignment: .leading, spacing: 2) { - Text("Search shortcut") - Text("Opens the search panel from any app.") + Text(L10n.text("Search shortcut")) + Text(L10n.text("Opens the search panel from any app.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } @@ -223,8 +224,8 @@ private struct GeneralSettings: View { state.launchAtLogin = state.app.launchesAtLogin })) { VStack(alignment: .leading, spacing: 2) { - Text("Open DigUp when you log in") - Text(state.loginProblem ?? "So the shortcut always works.") + Text(L10n.text("Open DigUp when you log in")) + Text(state.loginProblem ?? L10n.text("So the shortcut always works.")) .font(.system(size: 11)) .foregroundStyle(state.loginProblem == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(.red)) } @@ -236,18 +237,18 @@ private struct GeneralSettings: View { state.checksForUpdates = on })) { VStack(alignment: .leading, spacing: 2) { - Text("Check for updates automatically") - Text("Once a day DigUp asks GitHub for its newest version; nothing about your files is sent. " - + "Updates install with one click.") + Text(L10n.text("Check for updates automatically")) + Text(L10n.text("Once a day DigUp asks GitHub for its newest version; nothing about your files is sent. ") + + L10n.text("Updates install with one click.")) .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } LabeledContent { - Button("Check Now") { state.app.updates.checkNow() } + Button(L10n.text("Check Now")) { state.app.updates.checkNow() } } label: { - Text(state.model.updateAvailable.map { "DigUp \($0) is available" } + Text(state.model.updateAvailable.map { L10n.format("DigUp %1$@ is available", String(describing: $0)) } ?? "DigUp \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")") } } @@ -269,7 +270,7 @@ private struct FolderSettings: View { Section { if let summaries = state.summaries { if summaries.isEmpty { - Text("No folders yet. Add the ones DigUp should search.") + Text(L10n.text("No folders yet. Add the ones DigUp should search.")) .foregroundStyle(.secondary) } ForEach(summaries) { summary in @@ -279,11 +280,11 @@ private struct FolderSettings: View { ProgressView().controlSize(.small).frame(maxWidth: .infinity) } } header: { - Text("Folders to search") + Text(L10n.text("Folders to search")) } footer: { HStack { Button(action: state.addFolders) { - Label("Add Folders…", systemImage: "plus") + Label(L10n.text("Add Folders…"), systemImage: "plus") } Spacer() } @@ -302,25 +303,25 @@ private struct FolderSettings: View { Image(systemName: "minus.circle.fill").foregroundStyle(.secondary) } .buttonStyle(.plain) - .help("Search this folder again") + .help(L10n.text("Search this folder again")) } } if state.model.settings.excludedFolders.isEmpty { - Text("None. Code projects, app bundles and caches are skipped anyway (code has its own tab).") + Text(L10n.text("None. Code projects, app bundles and caches are skipped anyway (code has its own tab).")) .foregroundStyle(.secondary) } } header: { - Text("Skip these folders") + Text(L10n.text("Skip these folders")) } footer: { HStack { Button { - state.app.pickFolders(for: NSApp.keyWindow, prompt: "Skip") { folders in + state.app.pickFolders(for: NSApp.keyWindow, prompt: L10n.text("Skip")) { folders in guard !folders.isEmpty else { return } state.app.setExcluded(state.model.settings.excludedFolders + folders.filter { !state.model.settings.excludedFolders.contains($0) }) } } label: { - Label("Skip a Folder…", systemImage: "plus") + Label(L10n.text("Skip a Folder…"), systemImage: "plus") } Spacer() } @@ -328,11 +329,11 @@ private struct FolderSettings: View { Section { TypeExclusions(types: state.model.settings.excludedTypes) { state.app.setExcludedTypes($0) } } header: { - Text("Skip these file types") + Text(L10n.text("Skip these file types")) } footer: { - Text("DigUp only reads pictures, PDFs, documents (txt, md, rtf, docx, odt, html), audio and video here; " - + "code is read only in the code folders (the Code tab). Keys, certificates and password files are " - + "never read, nor are hidden folders like ~/.ssh.") + Text(L10n.text("DigUp only reads pictures, PDFs, documents (txt, md, rtf, docx, odt, html), audio and video here; ") + + L10n.text("code is read only in the code folders (the Code tab). Keys, certificates and password files are ") + + L10n.text("never read, nor are hidden folders like ~/.ssh.")) .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -351,21 +352,20 @@ private struct FolderSettings: View { state.app.setIndexHours(on: on, hours: state.model.settings.indexHours) })) { VStack(alignment: .leading, spacing: 2) { - Text("Index only at set hours") - Text("Files are read only between these times, and what changed meanwhile is picked up when " - + "they begin. Search works all day.") + Text(L10n.text("Index only at set hours")) + Text(L10n.text("Files are read only between these times, and what changed meanwhile is picked up when they begin. Search works all day.")) .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } if state.model.settings.indexHoursOn { - LabeledContent("From") { + LabeledContent(L10n.text("From")) { HStack(spacing: 6) { - DatePicker("From", selection: time(\.start), displayedComponents: .hourAndMinute) + DatePicker(L10n.text("From"), selection: time(\.start), displayedComponents: .hourAndMinute) .labelsHidden() - Text("to") - DatePicker("To", selection: time(\.end), displayedComponents: .hourAndMinute) + Text(L10n.text("to")) + DatePicker(L10n.text("To"), selection: time(\.end), displayedComponents: .hourAndMinute) .labelsHidden() } } @@ -377,19 +377,19 @@ private struct FolderSettings: View { .onChange(of: state.model.status) { state.statusChanged() } .confirmationDialog(removalTitle, isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } })) { - Button("Remove", role: .destructive) { + Button(L10n.text("Remove"), role: .destructive) { guard let removing else { return } state.app.removeFolder(URL(fileURLWithPath: removing.path)) Task { await state.refresh() } } } message: { - Text("Its \(removing?.files.formatted() ?? "") files leave the index. Adding it back later indexes them " - + "again.") + Text(L10n.format("Its %1$@ files leave the index. Adding it back later indexes them ", String(describing: removing?.files.formatted() ?? "")) + + L10n.text("again.")) } } private var removalTitle: String { - "Stop searching “\(removing.map { ($0.path as NSString).lastPathComponent } ?? "")”?" + L10n.format("Stop searching “%1$@”?", String(describing: removing.map { ($0.path as NSString).lastPathComponent } ?? "")) } /// One end of the set hours, as a time of day for a date picker. @@ -415,7 +415,7 @@ private struct TypeExclusions: View { @FocusState private var focused: Bool var body: some View { - TextField("Extensions", text: $text, prompt: Text("e.g. heic, txt")) + TextField(L10n.text("Extensions"), text: $text, prompt: Text(L10n.text("e.g. heic, txt"))) .labelsHidden() .focused($focused) .onSubmit { apply(text) } @@ -443,7 +443,7 @@ private struct IndexProgress: View { } Spacer() if model.hasFolders { - Button(model.status.paused ? "Resume" : "Pause", action: togglePause) + Button(model.status.paused ? L10n.text("Resume") : L10n.text("Pause"), action: togglePause) } } } @@ -451,12 +451,12 @@ private struct IndexProgress: View { /// "357 of 365 files searchable · 8 waiting · 2 long files to finish" private var detail: String { let status = model.status - var text = "\(status.searchable.formatted()) of \(status.files.formatted()) files searchable" - if status.pending > 0 { text += " · \(status.pending.formatted()) waiting" } + var text = L10n.format("%1$@ of %2$@ files searchable", String(describing: status.searchable.formatted()), String(describing: status.files.formatted())) + if status.pending > 0 { text += L10n.format(" · %1$@ waiting", String(describing: status.pending.formatted())) } if status.unfinished > 0 { - text += " · \(status.unfinished.formatted()) long \(status.unfinished == 1 ? "file" : "files") to finish" + text += L10n.format(" · %1$@ long %2$@ to finish", String(describing: status.unfinished.formatted()), String(describing: status.unfinished == 1 ? "file" : "files")) } - if model.download.isActive { text += " · model \(model.download.progressText)" } + if model.download.isActive { text += L10n.format(" · model %1$@", String(describing: model.download.progressText)) } return text } } @@ -475,14 +475,14 @@ private struct FolderStatusRow: View { HStack(spacing: 6) { Text((summary.path as NSString).lastPathComponent).font(.system(size: 13, weight: .semibold)) if summary.missing || summary.unreadable { - Text(summary.missing ? "Not connected" : "No access") + Text(summary.missing ? L10n.text("Not connected") : L10n.text("No access")) .font(.system(size: 10, weight: .semibold)) .padding(.horizontal, 6) .padding(.vertical, 1) .background(Capsule().fill(Color.orange.opacity(0.2))) } if summary.unreadable { - Button("Allow in Settings…", action: openFolderPrivacySettings) + Button(L10n.text("Allow in Settings…"), action: openFolderPrivacySettings) .buttonStyle(.link) .font(.system(size: 11)) } @@ -498,8 +498,8 @@ private struct FolderStatusRow: View { } Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 2) { - Text(summary.missing || summary.unreadable ? "Kept for when it's back" - : "\(summary.indexed.formatted()) of \(summary.files.formatted()) indexed") + Text(summary.missing || summary.unreadable ? L10n.text("Kept for when it's back") + : L10n.format("%1$@ of %2$@ indexed", String(describing: summary.indexed.formatted()), String(describing: summary.files.formatted()))) .font(.system(size: 12, weight: .medium).monospacedDigit()) if !states.isEmpty { Text(states).font(.system(size: 11)).foregroundStyle(.secondary) @@ -509,18 +509,18 @@ private struct FolderStatusRow: View { Image(systemName: "minus.circle.fill").foregroundStyle(.secondary) } .buttonStyle(.plain) - .help("Stop searching this folder") - .accessibilityLabel("Remove \((summary.path as NSString).lastPathComponent)") + .help(L10n.text("Stop searching this folder")) + .accessibilityLabel(L10n.format("Remove %1$@", String(describing: (summary.path as NSString).lastPathComponent))) } .padding(.vertical, 2) } private var states: String { - [summary.pending > 0 ? "\(summary.pending.formatted()) waiting" : nil, + [summary.pending > 0 ? L10n.format("%1$@ waiting", String(describing: summary.pending.formatted())) : nil, summary.unfinished > 0 - ? "\(summary.unfinished.formatted()) long \(summary.unfinished == 1 ? "file" : "files") to finish" : nil, - summary.skipped > 0 ? "\(summary.skipped.formatted()) skipped" : nil, - summary.failed > 0 ? "\(summary.failed.formatted()) couldn't be read" : nil].compactMap { $0 } + ? L10n.format("%1$@ long %2$@ to finish", String(describing: summary.unfinished.formatted()), String(describing: summary.unfinished == 1 ? "file" : "files")) : nil, + summary.skipped > 0 ? L10n.format("%1$@ skipped", String(describing: summary.skipped.formatted())) : nil, + summary.failed > 0 ? L10n.format("%1$@ couldn't be read", String(describing: summary.failed.formatted())) : nil].compactMap { $0 } .joined(separator: " · ") } } @@ -534,61 +534,60 @@ private struct StorageSettings: View { LabeledContent("EmbeddingGemma 2") { Text("Google · Apache 2.0 · 865 MB") } - LabeledContent("Status") { + LabeledContent(L10n.text("Status")) { HStack(spacing: 8) { - Text(state.model.download.isReady ? "Downloaded" : state.model.download.progressText) + Text(state.model.download.isReady ? L10n.text("Downloaded") : state.model.download.progressText) switch state.model.download.phase { case .needed, .failed: - Button("Download") { state.model.download.start() }.controlSize(.small) - Button("Use Files…") { state.model.download.chooseFiles(for: NSApp.keyWindow) } + Button(L10n.text("Download")) { state.model.download.start() }.controlSize(.small) + Button(L10n.text("Use Files…")) { state.model.download.chooseFiles(for: NSApp.keyWindow) } .controlSize(.small) default: EmptyView() } } } - LabeledContent("Location") { + LabeledContent(L10n.text("Location")) { PathButton(url: state.model.settings.modelsDirectory) } } header: { - Text("Model") + Text(L10n.text("Model")) } footer: { if !state.model.download.isReady { HStack(alignment: .firstTextBaseline) { - Text("If your network blocks Hugging Face, download the two files on another network and " - + "click Use Files… to choose them. DigUp checks them before it uses them.") + Text(L10n.text("If your network blocks Hugging Face, download the two files on another network and click Use Files… to choose them. DigUp checks them before it uses them.")) .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Spacer() - Button("Copy Links") { + Button(L10n.text("Copy Links")) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(ModelDownload.links, forType: .string) } .controlSize(.small) - .help("Copies the addresses of the two files") + .help(L10n.text("Copies the addresses of the two files")) } } } - Section("Index") { - LabeledContent("Size on disk", value: bytes(state.indexSize)) + Section(L10n.text("Index")) { + LabeledContent(L10n.text("Size on disk"), value: bytes(state.indexSize)) if state.model.hasCode { - LabeledContent("Code index", value: bytes(state.codeIndexSize)) + LabeledContent(L10n.text("Code index"), value: bytes(state.codeIndexSize)) } - LabeledContent("Location") { + LabeledContent(L10n.text("Location")) { PathButton(url: state.model.settings.indexDirectory) } } - Section("Privacy") { - Text("DigUp reads the folders you choose and keeps what it learns in its index, on this Mac. " - + "It goes online only to download its model, once, and to check for updates (General), " - + "which sends nothing about your files.") + Section(L10n.text("Privacy")) { + Text(L10n.text("DigUp reads the folders you choose and keeps what it learns in its index, on this Mac. ") + + L10n.text("It goes online only to download its model, once, and to check for updates (General), ") + + L10n.text("which sends nothing about your files.")) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Section { Text("DigUp \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "") · " - + "EmbeddingGemma 2 by Google (Apache 2.0), run by llama.cpp (MIT).") + + L10n.text("EmbeddingGemma 2 by Google (Apache 2.0), run by llama.cpp (MIT).")) .font(.system(size: 11)) .foregroundStyle(.secondary) } @@ -608,10 +607,10 @@ private struct CodeSettings: View { Form { Section { VStack(alignment: .leading, spacing: 6) { - Text("Search your code").font(.system(size: 13, weight: .semibold)) - Text("Find code by what it does, or by a name in it: type code: in the search panel, as in " - + "“code: retry with backoff”. Code has an index of its own, and it never shows up in " - + "other searches.") + Text(L10n.text("Search your code")).font(.system(size: 13, weight: .semibold)) + Text(L10n.text("Find code by what it does, or by a name in it: type code: in the search panel, as in ") + + L10n.text("“code: retry with backoff”. Code has an index of its own, and it never shows up in ") + + L10n.text("other searches.")) .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -633,44 +632,44 @@ private struct CodeSettings: View { } Section { if state.codeSummaries.isEmpty { - Text("No code folders yet. Add the folders that hold your projects.") + Text(L10n.text("No code folders yet. Add the folders that hold your projects.")) .foregroundStyle(.secondary) } ForEach(state.codeSummaries) { summary in FolderStatusRow(summary: summary) { removing = summary } } } header: { - Text("Code folders") + Text(L10n.text("Code folders")) } footer: { HStack { Button(action: state.addCodeFolders) { - Label(state.model.hasCode ? "Add Folders…" : "Choose Code Folders…", systemImage: "plus") + Label(state.model.hasCode ? L10n.text("Add Folders…") : L10n.text("Choose Code Folders…"), systemImage: "plus") } Spacer() } } Section { - Picker("Open code in", selection: Binding(get: { CodeEditor.current.id }, + Picker(L10n.text("Open code in"), selection: Binding(get: { CodeEditor.current.id }, set: { state.app.setCodeEditor($0) })) { ForEach(CodeEditor.installed) { editor in Text(editor.name).tag(editor.id) } Text(CodeEditor.defaultApp.name).tag(CodeEditor.defaultApp.id) } } footer: { - Text("A result opens there at the line that matched. Space shows the whole file in Quick Look.") + Text(L10n.text("A result opens there at the line that matched. Space shows the whole file in Quick Look.")) .font(.system(size: 11)) .foregroundStyle(.secondary) } Section { - Text("The code index keeps your code's text on this Mac, as the main index keeps your documents'. " - + "It skips what git ignores, vendored and generated code, data files, and anything that looks " - + "like a key or a password.") + Text(L10n.text("The code index keeps your code's text on this Mac, as the main index keeps your documents'. ") + + L10n.text("It skips what git ignores, vendored and generated code, data files, and anything that looks ") + + L10n.text("like a key or a password.")) .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) if state.model.hasCode { HStack { Spacer() - Button("Turn Off Code Search…", role: .destructive) { turningOff = true } + Button(L10n.text("Turn Off Code Search…"), role: .destructive) { turningOff = true } } } } @@ -678,23 +677,23 @@ private struct CodeSettings: View { .formStyle(.grouped) .scrollDisabled(true) .onChange(of: state.model.status) { state.statusChanged() } - .confirmationDialog("Turn off code search?", isPresented: $turningOff) { - Button("Turn Off", role: .destructive) { + .confirmationDialog(L10n.text("Turn off code search?"), isPresented: $turningOff) { + Button(L10n.text("Turn Off"), role: .destructive) { state.app.applyCodeFolders([], excluded: []) Task { await state.refresh() } } } message: { - Text("The code index (\(bytes(state.codeIndexSize))) is deleted. Turning it on again reads the code again.") + Text(L10n.format("The code index (%1$@) is deleted. Turning it on again reads the code again.", String(describing: bytes(state.codeIndexSize)))) } - .confirmationDialog("Stop searching the code in “\(removing.map { ($0.path as NSString).lastPathComponent } ?? "")”?", + .confirmationDialog(L10n.format("Stop searching the code in “%1$@”?", String(describing: removing.map { ($0.path as NSString).lastPathComponent } ?? "")), isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } })) { - Button("Remove", role: .destructive) { + Button(L10n.text("Remove"), role: .destructive) { guard let removing else { return } state.app.removeCodeFolder(URL(fileURLWithPath: removing.path)) Task { await state.refresh() } } } message: { - Text("Its \(removing?.files.formatted() ?? "") code files leave the code index.") + Text(L10n.format("Its %1$@ code files leave the code index.", String(describing: removing?.files.formatted() ?? ""))) } } } @@ -713,6 +712,6 @@ private struct PathButton: View { } } .buttonStyle(.plain) - .help("Show in Finder") + .help(L10n.text("Show in Finder")) } } diff --git a/Sources/DigUpApp/StatusMenu.swift b/Sources/DigUpApp/StatusMenu.swift index e2cd8117..b539c95b 100644 --- a/Sources/DigUpApp/StatusMenu.swift +++ b/Sources/DigUpApp/StatusMenu.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import SwiftUI @@ -105,39 +106,39 @@ final class StatusMenuController: NSObject, NSMenuDelegate { menu.addItem(header) menu.addItem(.separator()) if let version = model.updateAvailable { - menu.addItem(ActionMenuItem("Update to DigUp \(version)…") { [weak self] in self?.onCheckForUpdates() }) + menu.addItem(ActionMenuItem(L10n.format("Update to DigUp %1$@…", String(describing: version))) { [weak self] in self?.onCheckForUpdates() }) menu.addItem(.separator()) } if !model.hasFolders { - menu.addItem(ActionMenuItem("Choose Folders to Search…") { [weak self] in self?.onChooseFolders() }) + menu.addItem(ActionMenuItem(L10n.text("Choose Folders to Search…")) { [weak self] in self?.onChooseFolders() }) } - let search = ActionMenuItem("Search…") { [weak self] in self?.onSearch() } + let search = ActionMenuItem(L10n.text("Search…")) { [weak self] in self?.onSearch() } if let hotkey = model.hotkey, let (key, modifiers) = hotkey.menuEquivalent { search.keyEquivalent = key search.keyEquivalentModifierMask = modifiers } menu.addItem(search) - menu.addItem(ActionMenuItem("Open DigUp") { [weak self] in self?.onOpenWindow() }) + menu.addItem(ActionMenuItem(L10n.text("Open DigUp")) { [weak self] in self?.onOpenWindow() }) if model.hasFolders { menu.addItem(.separator()) let paused = model.status.paused - menu.addItem(ActionMenuItem(paused ? "Resume Indexing" : "Pause Indexing") { [weak self] in + menu.addItem(ActionMenuItem(paused ? L10n.text("Resume Indexing") : L10n.text("Pause Indexing")) { [weak self] in self?.onPauseChanged(!paused) }) } switch model.download.phase { case .failed, .needed: - menu.addItem(ActionMenuItem("Download the Model") { [weak self] in self?.model.download.start() }) + menu.addItem(ActionMenuItem(L10n.text("Download the Model")) { [weak self] in self?.model.download.start() }) default: break } menu.addItem(.separator()) - menu.addItem(ActionMenuItem("Settings…", key: ",") { [weak self] in self?.onSettings() }) + menu.addItem(ActionMenuItem(L10n.text("Settings…"), key: ",") { [weak self] in self?.onSettings() }) if model.updateAvailable == nil { - menu.addItem(ActionMenuItem("Check for Updates…") { [weak self] in self?.onCheckForUpdates() }) + menu.addItem(ActionMenuItem(L10n.text("Check for Updates…")) { [weak self] in self?.onCheckForUpdates() }) } - menu.addItem(ActionMenuItem("Quit DigUp", key: "q") { NSApp.terminate(nil) }) + menu.addItem(ActionMenuItem(L10n.text("Quit DigUp"), key: "q") { NSApp.terminate(nil) }) } /// The menu as text, for the -debugMenu launch argument (menus can't be clicked from a script). @@ -189,13 +190,13 @@ struct MenuStatusView: View { private var detail: String? { if model.download.isActive { return model.download.progressText } if case .failed(let message) = model.download.phase { return message } - guard model.hasFolders else { return "Pick the folders it should search." } + guard model.hasFolders else { return L10n.text("Pick the folders it should search.") } let missing = Set(model.status.missingRoots), unreadable = Set(model.status.unreadableRoots) let names = model.settings.roots.map { root in - root.lastPathComponent + (missing.contains(root.path) ? " (not connected)" - : unreadable.contains(root.path) ? " (no access)" : "") + root.lastPathComponent + (missing.contains(root.path) ? L10n.text(" (not connected)") + : unreadable.contains(root.path) ? L10n.text(" (no access)") : "") } - return "In " + names.joined(separator: ", ") + return L10n.text("In ") + names.joined(separator: ", ") } } diff --git a/Sources/DigUpLocalization/L10n.swift b/Sources/DigUpLocalization/L10n.swift new file mode 100644 index 00000000..3e63bfd5 --- /dev/null +++ b/Sources/DigUpLocalization/L10n.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Interface localization only. Queries, file paths, persisted settings and helper protocols keep their original values. +public enum L10n { + static let bundle = Bundle.module + + public static func text(_ key: String) -> String { + bundle.localizedString(forKey: key, value: nil, table: "Localizable") + } + + /// Positional arguments let a translation reorder display values without changing their formatting. + public static func format(_ key: String, _ arguments: CVarArg...) -> String { + String(format: text(key), locale: Locale.current, arguments: arguments) + } +} diff --git a/Sources/DigUpLocalization/Resources/en.lproj/InfoPlist.strings b/Sources/DigUpLocalization/Resources/en.lproj/InfoPlist.strings new file mode 100644 index 00000000..640c8a6e --- /dev/null +++ b/Sources/DigUpLocalization/Resources/en.lproj/InfoPlist.strings @@ -0,0 +1,5 @@ +"NSAppleEventsUsageDescription" = "DigUp asks QuickTime Player to open a recording at the moment that matched your search."; +"NSDesktopFolderUsageDescription" = "DigUp indexes the folders you choose so you can search them by describing what's inside. Everything stays on this Mac."; +"NSDocumentsFolderUsageDescription" = "DigUp indexes the folders you choose so you can search them by describing what's inside. Everything stays on this Mac."; +"NSDownloadsFolderUsageDescription" = "DigUp indexes the folders you choose so you can search them by describing what's inside. Everything stays on this Mac."; +"NSRemovableVolumesUsageDescription" = "DigUp indexes the folders you choose so you can search them by describing what's inside. Everything stays on this Mac."; diff --git a/Sources/DigUpLocalization/Resources/en.lproj/Localizable.strings b/Sources/DigUpLocalization/Resources/en.lproj/Localizable.strings new file mode 100644 index 00000000..4149e595 --- /dev/null +++ b/Sources/DigUpLocalization/Resources/en.lproj/Localizable.strings @@ -0,0 +1,427 @@ +/* DigUp interface strings. Positional %@ arguments contain already formatted display values. */ +" (about %1$@ on this Mac)" = " (about %1$@ on this Mac)"; +" (no access)" = " (no access)"; +" (not connected)" = " (not connected)"; +" + %1$@ later" = " + %1$@ later"; +" Long PDFs and documents get their first pages in that time, and the rest " = " Long PDFs and documents get their first pages in that time, and the rest "; +" at %1$@" = " at %1$@"; +" at line %1$@" = " at line %1$@"; +" in your code" = " in your code"; +" of " = " of "; +" · %1$@ long %2$@ to finish" = " · %1$@ long %2$@ to finish"; +" · %1$@ waiting" = " · %1$@ waiting"; +" · %1$@ weaker" = " · %1$@ weaker"; +" · model %1$@" = " · model %1$@"; +" · paused" = " · paused"; +"%.1f h" = "%.1f h"; +"%1$@ %2$@ indexed" = "%1$@ %2$@ indexed"; +"%1$@ %2$@ indexed yet" = "%1$@ %2$@ indexed yet"; +"%1$@ %2$@ waiting" = "%1$@ %2$@ waiting"; +"%1$@ cells" = "%1$@ cells"; +"%1$@ code %2$@ indexed" = "%1$@ code %2$@ indexed"; +"%1$@ code files waiting · %2$@" = "%1$@ code files waiting · %2$@"; +"%1$@ couldn't be read" = "%1$@ couldn't be read"; +"%1$@ files to search in %2$@" = "%1$@ files to search in %2$@"; +"%1$@ folders" = "%1$@ folders"; +"%1$@ lines" = "%1$@ lines"; +"%1$@ long %2$@" = "%1$@ long %2$@"; +"%1$@ long %2$@ to finish" = "%1$@ long %2$@ to finish"; +"%1$@ min" = "%1$@ min"; +"%1$@ of %2$@ files searchable" = "%1$@ of %2$@ files searchable"; +"%1$@ of %2$@ indexed" = "%1$@ of %2$@ indexed"; +"%1$@ pages" = "%1$@ pages"; +"%1$@ results" = "%1$@ results"; +"%1$@ s" = "%1$@ s"; +"%1$@ skipped" = "%1$@ skipped"; +"%1$@ to be indexed" = "%1$@ to be indexed"; +"%1$@ waiting" = "%1$@ waiting"; +"%1$@ weaker %2$@" = "%1$@ weaker %2$@"; +"%1$@ – playing from " = "%1$@ – playing from "; +"(%1$@). Until then, search finds files by name." = "(%1$@). Until then, search finds files by name."; +"+ %1$@ later" = "+ %1$@ later"; +", %1$@ read so far" = ", %1$@ read so far"; +", and %1$@ more for long files" = ", and %1$@ more for long files"; +", as part of “%1$@”." = ", as part of “%1$@”."; +"1 cell" = "1 cell"; +"1 line" = "1 line"; +"1 page" = "1 page"; +"1 result" = "1 result"; +"< 1 s" = "< 1 s"; +"A result opens there at the line that matched. Space shows the whole file in Quick Look." = "A result opens there at the line that matched. Space shows the whole file in Quick Look."; +"About %1$@" = "About %1$@"; +"About DigUp" = "About DigUp"; +"Add" = "Add"; +"Add Folders" = "Add Folders"; +"Add Folders…" = "Add Folders…"; +"Add Folder…" = "Add Folder…"; +"Add folders to search" = "Add folders to search"; +"Adds %1$@ of indexing" = "Adds %1$@ of indexing"; +"Adds %1$@ of indexing and %2$@" = "Adds %1$@ of indexing and %2$@"; +"All" = "All"; +"Allow in Settings…" = "Allow in Settings…"; +"Another app is using %1$@" = "Another app is using %1$@"; +"At %1$@ of %2$@" = "At %1$@ of %2$@"; +"Audio" = "Audio"; +"Back" = "Back"; +"Can't create %1$@" = "Can't create %1$@"; +"Can't open the index" = "Can't open the index"; +"Can't save the model: %1$@" = "Can't save the model: %1$@"; +"Can't start indexing" = "Can't start indexing"; +"Cancel" = "Cancel"; +"Check Now" = "Check Now"; +"Check for Updates…" = "Check for Updates…"; +"Check for updates automatically" = "Check for updates automatically"; +"Check the folders to add" = "Check the folders to add"; +"Check the folders with your code" = "Check the folders with your code"; +"Checking folders…" = "Checking folders…"; +"Checking the download…" = "Checking the download…"; +"Checking the model download…" = "Checking the model download…"; +"Choose Another Folder…" = "Choose Another Folder…"; +"Choose Code Folders…" = "Choose Code Folders…"; +"Choose Folders to Search…" = "Choose Folders to Search…"; +"Choose Folders…" = "Choose Folders…"; +"Choose among its subfolders" = "Choose among its subfolders"; +"Choose at least one folder" = "Choose at least one folder"; +"Choose folders for DigUp to search." = "Choose folders for DigUp to search."; +"Choose folders to search" = "Choose folders to search"; +"Clear" = "Clear"; +"Click to change" = "Click to change"; +"Click to set a shortcut" = "Click to set a shortcut"; +"Close" = "Close"; +"Code" = "Code"; +"Code Folders" = "Code Folders"; +"Code folders" = "Code folders"; +"Code index" = "Code index"; +"Code is read once the model has downloaded" = "Code is read once the model has downloaded"; +"Code search is off" = "Code search is off"; +"Code search reads source files, notebooks and a repo's own docs. It skips what git ignores, vendored " = "Code search reads source files, notebooks and a repo's own docs. It skips what git ignores, vendored "; +"Code, %1$@, %2$@" = "Code, %1$@, %2$@"; +"Code, keys, certificates, hidden folders, app bundles and caches are always skipped." = "Code, keys, certificates, hidden folders, app bundles and caches are always skipped."; +"Continue" = "Continue"; +"Copied %1$@" = "Copied %1$@"; +"Copy" = "Copy"; +"Copy Path" = "Copy Path"; +"Copy the file, to paste anywhere" = "Copy the file, to paste anywhere"; +"Couldn't look inside it; it can still be searched." = "Couldn't look inside it; it can still be searched."; +"Cut" = "Cut"; +"Describe it. Dig it up." = "Describe it. Dig it up."; +"Describe it: “payment error screenshot”, “zebra in a video”…" = "Describe it: “payment error screenshot”, “zebra in a video”…"; +"Describe it: “receipt from the hardware store”, “zebra in a video”…" = "Describe it: “receipt from the hardware store”, “zebra in a video”…"; +"Describe it: “zebra in a video”, “code: retry with backoff”…" = "Describe it: “zebra in a video”, “code: retry with backoff”…"; +"Describe the code: “retry a request with backoff”, a name in it…" = "Describe the code: “retry a request with backoff”, a name in it…"; +"DigUp %1$@ is available" = "DigUp %1$@ is available"; +"DigUp can find code by what it does, in the folders you pick. Code gets an index of its own " = "DigUp can find code by what it does, in the folders you pick. Code gets an index of its own "; +"DigUp can find code by what it does, in the folders you pick. Code gets an index of its own and " = "DigUp can find code by what it does, in the folders you pick. Code gets an index of its own and "; +"DigUp can find code by what it does: type code: in the search panel, as in " = "DigUp can find code by what it does: type code: in the search panel, as in "; +"DigUp is reading your folders%1$@, newest files first. You can search right away; " = "DigUp is reading your folders%1$@, newest files first. You can search right away; "; +"DigUp lives in the menu bar, which shows how indexing is going." = "DigUp lives in the menu bar, which shows how indexing is going."; +"DigUp only looks where you tell it to." = "DigUp only looks where you tell it to."; +"DigUp only reads files: it never changes, moves or uploads them." = "DigUp only reads files: it never changes, moves or uploads them."; +"DigUp only reads pictures, PDFs, documents (txt, md, rtf, docx, odt, html), audio and video here; " = "DigUp only reads pictures, PDFs, documents (txt, md, rtf, docx, odt, html), audio and video here; "; +"DigUp only searches the folders you pick." = "DigUp only searches the folders you pick."; +"DigUp reads a new folder once, then keeps up with what changes in it. " = "DigUp reads a new folder once, then keeps up with what changes in it. "; +"DigUp reads each once, then what changes. Code is searched on its own: type " = "DigUp reads each once, then what changes. Code is searched on its own: type "; +"DigUp reads the folders you choose and keeps what it learns in its index, on this Mac. " = "DigUp reads the folders you choose and keeps what it learns in its index, on this Mac. "; +"DigUp starts reading your folders%1$@ once the model has downloaded " = "DigUp starts reading your folders%1$@ once the model has downloaded "; +"Document" = "Document"; +"Documents" = "Documents"; +"Done" = "Done"; +"Download" = "Download"; +"Download the Model" = "Download the Model"; +"Downloaded" = "Downloaded"; +"Downloading model · %1$@%%" = "Downloading model · %1$@%%"; +"Downloading the model · %1$@" = "Downloading the model · %1$@"; +"Downloading the model · %1$@%%" = "Downloading the model · %1$@%%"; +"Edit" = "Edit"; +"EmbeddingGemma 2 by Google (Apache 2.0), run by llama.cpp (MIT)." = "EmbeddingGemma 2 by Google (Apache 2.0), run by llama.cpp (MIT)."; +"Esc cancels" = "Esc cancels"; +"Everything is indexed and searched on this Mac. DigUp downloads its AI model (865 MB) once; " = "Everything is indexed and searched on this Mac. DigUp downloads its AI model (865 MB) once; "; +"Extensions" = "Extensions"; +"File" = "File"; +"File types" = "File types"; +"Find code by what it does, or by a name in it: type code: in the search panel, as in " = "Find code by what it does, or by a name in it: type code: in the search panel, as in "; +"Finder's search window uses %1$@" = "Finder's search window uses %1$@"; +"First pass: about %1$@" = "First pass: about %1$@"; +"Folders" = "Folders"; +"Folders and indexing" = "Folders and indexing"; +"Folders to search" = "Folders to search"; +"General" = "General"; +"Get Started" = "Get Started"; +"Has your words" = "Has your words"; +"Hide DigUp" = "Hide DigUp"; +"Hide Preview" = "Hide Preview"; +"Hide its subfolders" = "Hide its subfolders"; +"Hide the subfolders of %1$@" = "Hide the subfolders of %1$@"; +"How long reading this code takes, once, and about how much room its index needs. After " = "How long reading this code takes, once, and about how much room its index needs. After "; +"Hugging Face answered %1$@" = "Hugging Face answered %1$@"; +"IN THE SEARCH PANEL" = "IN THE SEARCH PANEL"; +"Image" = "Image"; +"Images" = "Images"; +"In " = "In "; +"Index" = "Index"; +"Index on battery power" = "Index on battery power"; +"Indexing %1$@%%" = "Indexing %1$@%%"; +"Indexing %1$@%% · %2$@ of %3$@" = "Indexing %1$@%% · %2$@ of %3$@"; +"Indexing code %1$@%% · %2$@ of %3$@" = "Indexing code %1$@%% · %2$@ of %3$@"; +"Indexing paused" = "Indexing paused"; +"Indexing stopped" = "Indexing stopped"; +"It goes online only to download its model, once, and to check for updates (General), " = "It goes online only to download its model, once, and to check for updates (General), "; +"It skips what git ignores, vendored and generated code, data files, and anything that looks " = "It skips what git ignores, vendored and generated code, data files, and anything that looks "; +"Its %1$@ code files leave the code index." = "Its %1$@ code files leave the code index."; +"Its %1$@ files leave the index. Adding it back later indexes them " = "Its %1$@ files leave the index. Adding it back later indexes them "; +"Kept for when it's back" = "Kept for when it's back"; +"Location" = "Location"; +"Looking at the folders…" = "Looking at the folders…"; +"Looking at your code…" = "Looking at your code…"; +"Looking at your folders…" = "Looking at your folders…"; +"Low Power Mode" = "Low Power Mode"; +"Mac is warm" = "Mac is warm"; +"Matches %1$@" = "Matches %1$@"; +"Matches its name" = "Matches its name"; +"Matches its text" = "Matches its text"; +"Matches the frame at %1$@" = "Matches the frame at %1$@"; +"Matches the picture" = "Matches the picture"; +"Matches the sound at %1$@" = "Matches the sound at %1$@"; +"Matches the text in it" = "Matches the text in it"; +"Matches the text on %1$@" = "Matches the text on %1$@"; +"Matches what's heard at %1$@" = "Matches what's heard at %1$@"; +"Matches what's on screen" = "Matches what's on screen"; +"Matching words only until the model has downloaded (%1$@%%)" = "Matching words only until the model has downloaded (%1$@%%)"; +"Matching words only: the model isn't downloaded" = "Matching words only: the model isn't downloaded"; +"Matching words; meaning search is warming up…" = "Matching words; meaning search is warming up…"; +"Matching words…" = "Matching words…"; +"Minimize" = "Minimize"; +"Model" = "Model"; +"Model download: waiting for the network" = "Model download: waiting for the network"; +"Model not downloaded" = "Model not downloaded"; +"Model ready" = "Model ready"; +"Moments inside audio and video" = "Moments inside audio and video"; +"Needs %1$@ free on this disk" = "Needs %1$@ free on this disk"; +"New Search" = "New Search"; +"No access" = "No access"; +"No code folders yet. Add the folders that hold your projects." = "No code folders yet. Add the folders that hold your projects."; +"No code here" = "No code here"; +"No folders chosen yet" = "No folders chosen yet"; +"No folders yet. Add the ones DigUp should search." = "No folders yet. Add the ones DigUp should search."; +"No good matches" = "No good matches"; +"No good matches for “%1$@”" = "No good matches for “%1$@”"; +"No good matches in your code" = "No good matches in your code"; +"None. Code projects, app bundles and caches are skipped anyway (code has its own tab)." = "None. Code projects, app bundles and caches are skipped anyway (code has its own tab)."; +"Not connected" = "Not connected"; +"Not downloaded yet" = "Not downloaded yet"; +"Nothing to search here yet" = "Nothing to search here yet"; +"Nothing to search in the subfolders checked" = "Nothing to search in the subfolders checked"; +"Off unless you turn it on." = "Off unless you turn it on."; +"Once a day DigUp asks GitHub for its newest version; nothing about your files is sent. " = "Once a day DigUp asks GitHub for its newest version; nothing about your files is sent. "; +"Open" = "Open"; +"Open DigUp" = "Open DigUp"; +"Open DigUp from anywhere" = "Open DigUp from anywhere"; +"Open DigUp when you log in" = "Open DigUp when you log in"; +"Open a folder to pick some of its subfolders." = "Open a folder to pick some of its subfolders."; +"Open at %1$@" = "Open at %1$@"; +"Open code in" = "Open code in"; +"Open in %1$@" = "Open in %1$@"; +"Open the file" = "Open the file"; +"Open them in the DigUp window (⌘O shows everything)" = "Open them in the DigUp window (⌘O shows everything)"; +"Opens the search panel from any app." = "Opens the search panel from any app."; +"PDFs" = "PDFs"; +"PDFs and documents" = "PDFs and documents"; +"Part %1$@ of %2$@" = "Part %1$@ of %2$@"; +"Paste" = "Paste"; +"Pause" = "Pause"; +"Pause Indexing" = "Pause Indexing"; +"Paused · %1$@" = "Paused · %1$@"; +"Paused · %1$@ code files waiting" = "Paused · %1$@ code files waiting"; +"Pick folders, or open one to pick some of its subfolders. You can change this " = "Pick folders, or open one to pick some of its subfolders. You can change this "; +"Pick the folders it should search." = "Pick the folders it should search."; +"Pick the folders that hold your projects; open one to pick among its repos. " = "Pick the folders that hold your projects; open one to pick among its repos. "; +"Play" = "Play"; +"Play from %1$@" = "Play from %1$@"; +"Press the new shortcut…" = "Press the new shortcut…"; +"Press the shortcut in any app, describe what you're looking for, and press Return." = "Press the shortcut in any app, describe what you're looking for, and press Return."; +"Privacy" = "Privacy"; +"Quick Look, after arrowing to a result" = "Quick Look, after arrowing to a result"; +"Quit DigUp" = "Quit DigUp"; +"Reading the rest of %1$@ · %2$@%%" = "Reading the rest of %1$@ · %2$@%%"; +"Ready" = "Ready"; +"Recording a new shortcut" = "Recording a new shortcut"; +"Redo" = "Redo"; +"Remove" = "Remove"; +"Remove %1$@" = "Remove %1$@"; +"Resume" = "Resume"; +"Resume Indexing" = "Resume Indexing"; +"Say what the code does, or type a name that's in it." = "Say what the code does, or type a name that's in it."; +"Screenshot" = "Screenshot"; +"Screenshots" = "Screenshots"; +"Screenshots and photos" = "Screenshots and photos"; +"Screenshots use %1$@" = "Screenshots use %1$@"; +"Search" = "Search"; +"Search for “%1$@”" = "Search for “%1$@”"; +"Search my code" = "Search my code"; +"Search panel open" = "Search panel open"; +"Search shortcut" = "Search shortcut"; +"Search this folder again" = "Search this folder again"; +"Search your code" = "Search your code"; +"Search your code too?" = "Search your code too?"; +"Searching your code" = "Searching your code"; +"Searching…" = "Searching…"; +"Search…" = "Search…"; +"See what's new and update" = "See what's new and update"; +"Select All" = "Select All"; +"Settings (⌘,)" = "Settings (⌘,)"; +"Settings…" = "Settings…"; +"Shortcut %1$@, click to change" = "Shortcut %1$@, click to change"; +"Show %1$@ more" = "Show %1$@ more"; +"Show Preview" = "Show Preview"; +"Show all %1$@ %2$@…" = "Show all %1$@ %2$@…"; +"Show in Finder" = "Show in Finder"; +"Show it in Finder" = "Show it in Finder"; +"Show the subfolders of %1$@" = "Show the subfolders of %1$@"; +"Size on disk" = "Size on disk"; +"Skip" = "Skip"; +"Skip a Folder…" = "Skip a Folder…"; +"Skip some folders or file types" = "Skip some folders or file types"; +"Skip these file types" = "Skip these file types"; +"Skip these folders" = "Skip these folders"; +"So the shortcut always works." = "So the shortcut always works."; +"Spotlight uses %1$@" = "Spotlight uses %1$@"; +"Starting…" = "Starting…"; +"Status" = "Status"; +"Step %1$@ of %2$@" = "Step %1$@ of %2$@"; +"Still indexing (%1$@%%): some files aren't in yet" = "Still indexing (%1$@%%): some files aren't in yet"; +"Still reading code (%1$@%%): some isn't in yet" = "Still reading code (%1$@%%): some isn't in yet"; +"Stop searching the code in “%1$@”?" = "Stop searching the code in “%1$@”?"; +"Stop searching this folder" = "Stop searching this folder"; +"Stop searching “%1$@”?" = "Stop searching “%1$@”?"; +"Storage" = "Storage"; +"Switching input sources uses %1$@" = "Switching input sources uses %1$@"; +"TRY" = "TRY"; +"That's it. It works from any app." = "That's it. It works from any app."; +"The code index (%1$@) is deleted. Turning it on again reads the code again." = "The code index (%1$@) is deleted. Turning it on again reads the code again."; +"The code index keeps your code's text on this Mac, as the main index keeps your documents'. " = "The code index keeps your code's text on this Mac, as the main index keeps your documents'. "; +"The download couldn't resume" = "The download couldn't resume"; +"The download stopped early" = "The download stopped early"; +"The download was damaged twice. Try again later." = "The download was damaged twice. Try again later."; +"The emoji picker uses %1$@" = "The emoji picker uses %1$@"; +"The file's default app" = "The file's default app"; +"The first pass takes this long. Long PDFs and documents get their first pages in it, " = "The first pass takes this long. Long PDFs and documents get their first pages in it, "; +"The model isn't downloaded yet" = "The model isn't downloaded yet"; +"They match much less well than the results above. Press Return to " = "They match much less well than the results above. Press Return to "; +"Try Again" = "Try Again"; +"Try describing what's in it: what you'd see or hear, or words you remember." = "Try describing what's in it: what you'd see or hear, or words you remember."; +"Try it now: press %1$@." = "Try it now: press %1$@."; +"Try saying what the code does, or a name that's in it." = "Try saying what the code does, or a name that's in it."; +"Try what you'd see or hear in it, or words you remember from it." = "Try what you'd see or hear in it, or words you remember from it."; +"Turn Off" = "Turn Off"; +"Turn Off Code Search…" = "Turn Off Code Search…"; +"Turn off code search?" = "Turn off code search?"; +"Type what you remember in the search field above: what's in it, what it looks or sounds like, or " = "Type what you remember in the search field above: what's in it, what it looks or sounds like, or "; +"Under a second" = "Under a second"; +"Undo" = "Undo"; +"Update to DigUp %1$@…" = "Update to DigUp %1$@…"; +"Updates install with one click." = "Updates install with one click."; +"Use ⌘, ⌥ or ⌃ with a key" = "Use ⌘, ⌥ or ⌃ with a key"; +"Video" = "Video"; +"Waiting for a network connection" = "Waiting for a network connection"; +"Waiting for the network…" = "Waiting for the network…"; +"Weaker matches" = "Weaker matches"; +"Welcome to DigUp" = "Welcome to DigUp"; +"What should DigUp search?" = "What should DigUp search?"; +"When it's off, a folder's first pass waits for a charger. New files always go in right away." = "When it's off, a folder's first pass waits for a charger. New files always go in right away."; +"Window" = "Window"; +"Words only: meaning search isn't available" = "Words only: meaning search isn't available"; +"You're all set" = "You're all set"; +"a dog on the beach" = "a dog on the beach"; +"a page" = "a page"; +"a screenshot of an error message" = "a screenshot of an error message"; +"about %1$@" = "about %1$@"; +"about %1$@ left" = "about %1$@ left"; +"after that it works offline, and nothing you index ever leaves your Mac." = "after that it works offline, and nothing you index ever leaves your Mac."; +"afterwards (about %1$@ more)." = "afterwards (about %1$@ more)."; +"again." = "again."; +"and generated code, data files, and anything that looks like a key or a password." = "and generated code, data files, and anything that looks like a key or a password."; +"and never shows up in other searches." = "and never shows up in other searches."; +"and the rest afterwards, once everything else is in." = "and the rest afterwards, once everything else is in."; +"anytime in Settings." = "anytime in Settings."; +"audio file" = "audio file"; +"audio files" = "audio files"; +"cell %1$@" = "cell %1$@"; +"cells %1$@–%2$@" = "cells %1$@–%2$@"; +"code file" = "code file"; +"code files" = "code files"; +"code is read only in the code folders (the Code tab). Keys, certificates and password files are " = "code is read only in the code folders (the Code tab). Keys, certificates and password files are "; +"code: in the search panel." = "code: in the search panel."; +"code: parse the command line options" = "code: parse the command line options"; +"code: retry a request with backoff" = "code: retry a request with backoff"; +"code: where the settings are saved" = "code: where the settings are saved"; +"document" = "document"; +"documents" = "documents"; +"e.g. heic, txt" = "e.g. heic, txt"; +"fold them away." = "fold them away."; +"image" = "image"; +"images" = "images"; +"in %1$@" = "in %1$@"; +"less than a minute left" = "less than a minute left"; +"like a key or a password." = "like a key or a password."; +"line %1$@" = "line %1$@"; +"lines %1$@–%2$@" = "lines %1$@–%2$@"; +"long files" = "long files"; +"macOS isn't letting DigUp read it." = "macOS isn't letting DigUp read it."; +"macOS uses %1$@ for one of its shortcuts" = "macOS uses %1$@ for one of its shortcuts"; +"macOS will ask before DigUp can read folders like Desktop, Documents and Downloads. " = "macOS will ask before DigUp can read folders like Desktop, Documents and Downloads. "; +"never read, nor are hidden folders like ~/.ssh." = "never read, nor are hidden folders like ~/.ssh."; +"never shows up in other searches." = "never shows up in other searches."; +"on battery" = "on battery"; +"opens DigUp from any app" = "opens DigUp from any app"; +"other searches." = "other searches."; +"p. %1$@" = "p. %1$@"; +"page %1$@" = "page %1$@"; +"partly read so far" = "partly read so far"; +"results fill in as it goes." = "results fill in as it goes."; +"screenshot" = "screenshot"; +"screenshots" = "screenshots"; +"searches. You can turn it on later in Settings → Code." = "searches. You can turn it on later in Settings → Code."; +"show them." = "show them."; +"slides about the budget" = "slides about the budget"; +"someone laughing" = "someone laughing"; +"that, only what changes is read again." = "that, only what changes is read again."; +"under a second" = "under a second"; +"video" = "video"; +"videos" = "videos"; +"which sends nothing about your files." = "which sends nothing about your files."; +"whiteboard notes" = "whiteboard notes"; +"words from it." = "words from it."; +"“%1$@” is searched already" = "“%1$@” is searched already"; +"“%1$@” isn't searched anyway." = "“%1$@” isn't searched anyway."; +"“code: retry with backoff”. Code has an index of its own, and it never shows up in " = "“code: retry with backoff”. Code has an index of its own, and it never shows up in "; +"“code: retry with backoff”. It gets an index of its own and never shows up in other " = "“code: retry with backoff”. It gets an index of its own and never shows up in other "; +"“that payment error”, “the zebra”, “whiteboard from Tuesday”" = "“that payment error”, “the zebra”, “whiteboard from Tuesday”"; +"“the clause about pets in the lease”, “flight receipt”" = "“the clause about pets in the lease”, “flight receipt”"; +"“where they talk about sleep”, “the part with the drums”" = "“where they talk about sleep”, “the part with the drums”"; +"↩ %1$@ ⌘↩ Reveal ␣ Quick Look%2$@ ⌘C Copy" = "↩ %1$@ ⌘↩ Reveal ␣ Quick Look%2$@ ⌘C Copy"; +"↩ %1$@%2$@ ⌘↩ Reveal ␣ Quick Look ⌘C Copy" = "↩ %1$@%2$@ ⌘↩ Reveal ␣ Quick Look ⌘C Copy"; +"⚠︎ The model download stopped" = "⚠︎ The model download stopped"; +"the code" = "the code"; +"none" = "none"; + +/* Added in DigUp 0.6.1. */ +" and " = " and "; +"%1$@ %2$@ indexed · %3$@" = "%1$@ %2$@ indexed · %3$@"; +"%1$@ code %2$@ waiting · read after your other files" = "%1$@ code %2$@ waiting · read after your other files"; +"%1$@ code files waiting · indexing from %2$@" = "%1$@ code files waiting · indexing from %2$@"; +"Choose the model files you downloaded: %1$@." = "Choose the model files you downloaded: %1$@."; +"Copies the addresses of the two files" = "Copies the addresses of the two files"; +"Copy Links" = "Copy Links"; +"Downloaded the model's two files somewhere else? Choose them here." = "Downloaded the model's two files somewhere else? Choose them here."; +"Files are read only between these times, and what changed meanwhile is picked up when they begin. Search works all day." = "Files are read only between these times, and what changed meanwhile is picked up when they begin. Search works all day."; +"From" = "From"; +"If your network blocks Hugging Face, download the two files on another network and click Use Files… to choose them. DigUp checks them before it uses them." = "If your network blocks Hugging Face, download the two files on another network and click Use Files… to choose them. DigUp checks them before it uses them."; +"Index only at set hours" = "Index only at set hours"; +"The chosen %1$@ is %2$@, not %3$@" = "The chosen %1$@ is %2$@, not %3$@"; +"The chosen %1$@ isn't the right file" = "The chosen %1$@ isn't the right file"; +"Those aren't the model's files" = "Those aren't the model's files"; +"To" = "To"; +"Use" = "Use"; +"Use Files…" = "Use Files…"; +"indexing from %1$@" = "indexing from %1$@"; +"to" = "to"; diff --git a/Sources/DigUpLocalization/Resources/zh-Hant.lproj/InfoPlist.strings b/Sources/DigUpLocalization/Resources/zh-Hant.lproj/InfoPlist.strings new file mode 100644 index 00000000..9a15a5be --- /dev/null +++ b/Sources/DigUpLocalization/Resources/zh-Hant.lproj/InfoPlist.strings @@ -0,0 +1,5 @@ +"NSAppleEventsUsageDescription" = "DigUp 會請 QuickTime Player 開啟錄音或影片,並跳到符合搜尋內容的時間點。"; +"NSDesktopFolderUsageDescription" = "DigUp 會為你選擇的資料夾建立索引,讓你依內容描述搜尋檔案。所有內容都留在這台 Mac 上。"; +"NSDocumentsFolderUsageDescription" = "DigUp 會為你選擇的資料夾建立索引,讓你依內容描述搜尋檔案。所有內容都留在這台 Mac 上。"; +"NSDownloadsFolderUsageDescription" = "DigUp 會為你選擇的資料夾建立索引,讓你依內容描述搜尋檔案。所有內容都留在這台 Mac 上。"; +"NSRemovableVolumesUsageDescription" = "DigUp 會為你選擇的資料夾建立索引,讓你依內容描述搜尋檔案。所有內容都留在這台 Mac 上。"; diff --git a/Sources/DigUpLocalization/Resources/zh-Hant.lproj/Localizable.strings b/Sources/DigUpLocalization/Resources/zh-Hant.lproj/Localizable.strings new file mode 100644 index 00000000..70eed817 --- /dev/null +++ b/Sources/DigUpLocalization/Resources/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,427 @@ +/* DigUp interface strings. Positional %@ arguments contain already formatted display values. */ +" (about %1$@ on this Mac)" = "(這台 Mac 約需 %1$@)"; +" (no access)" = "(沒有存取權限)"; +" (not connected)" = "(未連接)"; +" + %1$@ later" = ",稍後另需 %1$@"; +" Long PDFs and documents get their first pages in that time, and the rest " = " 較長的 PDF 與文件會先讀取前幾頁,其餘內容"; +" at %1$@" = ",%1$@"; +" at line %1$@" = ",第 %1$@ 行"; +" in your code" = ",位於程式碼中"; +" of " = "/"; +" · %1$@ long %2$@ to finish" = " · %1$@ 個較長檔案尚未完成"; +" · %1$@ waiting" = " · %1$@ 個待處理"; +" · %1$@ weaker" = " · %1$@ 筆較不相關"; +" · model %1$@" = " · 模型:%1$@"; +" · paused" = " · 已暫停"; +"%.1f h" = "%.1f 小時"; +"%1$@ %2$@ indexed" = "%1$@ 個檔案已建立索引"; +"%1$@ %2$@ indexed yet" = "%1$@ 個檔案尚未建立索引"; +"%1$@ %2$@ waiting" = "%1$@ 個檔案待處理"; +"%1$@ cells" = "%1$@ 個儲存格"; +"%1$@ code %2$@ indexed" = "%1$@ 個程式碼檔案已建立索引"; +"%1$@ code files waiting · %2$@" = "%1$@ 個程式碼檔案待處理 · %2$@"; +"%1$@ couldn't be read" = "%1$@ 個無法讀取"; +"%1$@ files to search in %2$@" = "%2$@ 中有 %1$@ 個檔案可搜尋"; +"%1$@ folders" = "%1$@ 個資料夾"; +"%1$@ lines" = "%1$@ 行"; +"%1$@ long %2$@" = "%1$@ 個較長檔案"; +"%1$@ long %2$@ to finish" = "%1$@ 個較長檔案尚未完成"; +"%1$@ min" = "%1$@ 分鐘"; +"%1$@ of %2$@ files searchable" = "%2$@ 個檔案中,%1$@ 個可搜尋"; +"%1$@ of %2$@ indexed" = "%2$@ 個檔案中,%1$@ 個已建立索引"; +"%1$@ pages" = "%1$@ 頁"; +"%1$@ results" = "%1$@ 筆結果"; +"%1$@ s" = "%1$@ 秒"; +"%1$@ skipped" = "%1$@ 個已略過"; +"%1$@ to be indexed" = "%1$@,等待建立索引"; +"%1$@ waiting" = "%1$@ 個待處理"; +"%1$@ weaker %2$@" = "%1$@ 筆較不相關的結果"; +"%1$@ – playing from " = "%1$@ – 播放起點:"; +"(%1$@). Until then, search finds files by name." = "(%1$@)。在此之前,可依檔名搜尋。"; +"+ %1$@ later" = "稍後另需 %1$@"; +", %1$@ read so far" = ",已讀取 %1$@ 頁"; +", and %1$@ more for long files" = ",較長檔案另需 %1$@"; +", as part of “%1$@”." = ",包含於「%1$@」。"; +"1 cell" = "1 個儲存格"; +"1 line" = "1 行"; +"1 page" = "1 頁"; +"1 result" = "1 筆結果"; +"< 1 s" = "不到 1 秒"; +"A result opens there at the line that matched. Space shows the whole file in Quick Look." = "搜尋結果會在所選 App 中開啟並跳到符合的行。按空白鍵可用「快速查看」預覽整份檔案。"; +"About %1$@" = "約 %1$@"; +"About DigUp" = "關於 DigUp"; +"Add" = "加入"; +"Add Folders" = "加入資料夾"; +"Add Folders…" = "加入資料夾…"; +"Add Folder…" = "加入資料夾…"; +"Add folders to search" = "加入搜尋資料夾"; +"Adds %1$@ of indexing" = "建立索引另需 %1$@"; +"Adds %1$@ of indexing and %2$@" = "建立索引另需 %1$@,使用約 %2$@"; +"All" = "全部"; +"Allow in Settings…" = "在系統設定中允許…"; +"Another app is using %1$@" = "另一個 App 正在使用 %1$@"; +"At %1$@ of %2$@" = "%1$@,總長 %2$@"; +"Audio" = "音訊"; +"Back" = "上一步"; +"Can't create %1$@" = "無法建立 %1$@"; +"Can't open the index" = "無法開啟索引"; +"Can't save the model: %1$@" = "無法儲存模型:%1$@"; +"Can't start indexing" = "無法開始建立索引"; +"Cancel" = "取消"; +"Check Now" = "立即檢查"; +"Check for Updates…" = "檢查更新…"; +"Check for updates automatically" = "自動檢查更新"; +"Check the folders to add" = "勾選要加入的資料夾"; +"Check the folders with your code" = "勾選存放程式碼的資料夾"; +"Checking folders…" = "檢查資料夾中…"; +"Checking the download…" = "檢查下載內容中…"; +"Checking the model download…" = "檢查模型下載中…"; +"Choose Another Folder…" = "選擇其他資料夾…"; +"Choose Code Folders…" = "選擇程式碼資料夾…"; +"Choose Folders to Search…" = "選擇搜尋資料夾…"; +"Choose Folders…" = "選擇資料夾…"; +"Choose among its subfolders" = "選擇子資料夾"; +"Choose at least one folder" = "請至少選擇一個資料夾"; +"Choose folders for DigUp to search." = "選擇要讓 DigUp 搜尋的資料夾。"; +"Choose folders to search" = "選擇搜尋資料夾"; +"Clear" = "清除"; +"Click to change" = "按一下更改"; +"Click to set a shortcut" = "按一下設定快捷鍵"; +"Close" = "關閉"; +"Code" = "程式碼"; +"Code Folders" = "程式碼資料夾"; +"Code folders" = "程式碼資料夾"; +"Code index" = "程式碼索引"; +"Code is read once the model has downloaded" = "模型下載完成後會讀取程式碼"; +"Code search is off" = "程式碼搜尋已關閉"; +"Code search reads source files, notebooks and a repo's own docs. It skips what git ignores, vendored " = "程式碼搜尋會讀取原始碼、筆記本與儲存庫的文件。它會略過 git 忽略的內容、第三方"; +"Code, %1$@, %2$@" = "程式碼,%1$@,%2$@"; +"Code, keys, certificates, hidden folders, app bundles and caches are always skipped." = "程式碼、金鑰、憑證、隱藏資料夾、App 與快取一律略過。"; +"Continue" = "繼續"; +"Copied %1$@" = "已複製 %1$@"; +"Copy" = "複製"; +"Copy Path" = "複製路徑"; +"Copy the file, to paste anywhere" = "複製檔案,可貼到其他地方"; +"Couldn't look inside it; it can still be searched." = "無法檢視內部內容,仍可納入搜尋。"; +"Cut" = "剪下"; +"Describe it. Dig it up." = "說出印象,找出檔案。"; +"Describe it: “payment error screenshot”, “zebra in a video”…" = "描述你要找的內容,例如「付款錯誤截圖」、「影片中的斑馬」…"; +"Describe it: “receipt from the hardware store”, “zebra in a video”…" = "描述你要找的內容,例如「五金行收據」、「影片中的斑馬」…"; +"Describe it: “zebra in a video”, “code: retry with backoff”…" = "描述你要找的內容,例如「影片中的斑馬」、「code: 逐次延長等待時間後重試」…"; +"Describe the code: “retry a request with backoff”, a name in it…" = "描述程式碼,例如「逐次延長等待時間後重試請求」,或輸入其中的名稱…"; +"DigUp %1$@ is available" = "DigUp %1$@ 已推出"; +"DigUp can find code by what it does, in the folders you pick. Code gets an index of its own " = "DigUp 可依功能描述尋找所選資料夾中的程式碼。程式碼使用獨立索引,"; +"DigUp can find code by what it does, in the folders you pick. Code gets an index of its own and " = "DigUp 可依功能描述尋找所選資料夾中的程式碼。程式碼使用獨立索引,"; +"DigUp can find code by what it does: type code: in the search panel, as in " = "DigUp 可依功能描述尋找程式碼:在搜尋面板輸入 code:,例如"; +"DigUp is reading your folders%1$@, newest files first. You can search right away; " = "DigUp 正在讀取資料夾%1$@,優先處理最新檔案。你可以立即搜尋;"; +"DigUp lives in the menu bar, which shows how indexing is going." = "DigUp 常駐選單列,並顯示索引建立進度。"; +"DigUp only looks where you tell it to." = "DigUp 只搜尋你指定的資料夾。"; +"DigUp only reads files: it never changes, moves or uploads them." = "DigUp 只讀取檔案,不會修改、移動或上傳。"; +"DigUp only reads pictures, PDFs, documents (txt, md, rtf, docx, odt, html), audio and video here; " = "DigUp 在這裡只讀取圖片、PDF、文件(txt、md、rtf、docx、odt、html)、音訊與影片;"; +"DigUp only searches the folders you pick." = "DigUp 只搜尋你選擇的資料夾。"; +"DigUp reads a new folder once, then keeps up with what changes in it. " = "DigUp 首次讀取新資料夾的完整內容,之後持續處理其中的變更。"; +"DigUp reads each once, then what changes. Code is searched on its own: type " = "DigUp 首次讀取完整內容,之後只處理變更。程式碼獨立搜尋:請在搜尋面板輸入"; +"DigUp reads the folders you choose and keeps what it learns in its index, on this Mac. " = "DigUp 讀取你選擇的資料夾,並將索引儲存在這台 Mac 上。"; +"DigUp starts reading your folders%1$@ once the model has downloaded " = "模型下載完成後,DigUp 會開始讀取資料夾%1$@"; +"Document" = "文件"; +"Documents" = "文件"; +"Done" = "完成"; +"Download" = "下載"; +"Download the Model" = "下載模型"; +"Downloaded" = "已下載"; +"Downloading model · %1$@%%" = "下載模型中 · %1$@%%"; +"Downloading the model · %1$@" = "下載模型中 · %1$@"; +"Downloading the model · %1$@%%" = "下載模型中 · %1$@%%"; +"Edit" = "編輯"; +"EmbeddingGemma 2 by Google (Apache 2.0), run by llama.cpp (MIT)." = "Google 的 EmbeddingGemma 2(Apache 2.0),由 llama.cpp(MIT)執行。"; +"Esc cancels" = "按 Esc 取消"; +"Everything is indexed and searched on this Mac. DigUp downloads its AI model (865 MB) once; " = "所有索引與搜尋都在這台 Mac 上進行。DigUp 只需下載一次 AI 模型(865 MB);"; +"Extensions" = "副檔名"; +"File" = "檔案"; +"File types" = "檔案類型"; +"Find code by what it does, or by a name in it: type code: in the search panel, as in " = "依功能描述或名稱尋找程式碼:在搜尋面板輸入 code:,例如"; +"Finder's search window uses %1$@" = "Finder 搜尋視窗已使用 %1$@"; +"First pass: about %1$@" = "首次建立索引:約 %1$@"; +"Folders" = "資料夾"; +"Folders and indexing" = "資料夾與索引"; +"Folders to search" = "搜尋範圍"; +"General" = "一般"; +"Get Started" = "開始使用"; +"Has your words" = "包含搜尋文字"; +"Hide DigUp" = "隱藏 DigUp"; +"Hide Preview" = "隱藏預覽"; +"Hide its subfolders" = "隱藏子資料夾"; +"Hide the subfolders of %1$@" = "隱藏 %1$@ 的子資料夾"; +"How long reading this code takes, once, and about how much room its index needs. After " = "首次讀取程式碼所需的時間,以及索引的預估使用空間。"; +"Hugging Face answered %1$@" = "Hugging Face 回傳狀態碼 %1$@"; +"IN THE SEARCH PANEL" = "在搜尋面板中"; +"Image" = "圖片"; +"Images" = "圖片"; +"In " = "搜尋範圍:"; +"Index" = "索引"; +"Index on battery power" = "使用電池時建立索引"; +"Indexing %1$@%%" = "建立索引中 %1$@%%"; +"Indexing %1$@%% · %2$@ of %3$@" = "建立索引中 %1$@%% · %2$@/%3$@"; +"Indexing code %1$@%% · %2$@ of %3$@" = "建立程式碼索引中 %1$@%% · %2$@/%3$@"; +"Indexing paused" = "索引建立已暫停"; +"Indexing stopped" = "索引建立已停止"; +"It goes online only to download its model, once, and to check for updates (General), " = "只有首次下載模型與檢查更新時才會連線(可在「一般」中設定),"; +"It skips what git ignores, vendored and generated code, data files, and anything that looks " = "它會略過 git 忽略的檔案、第三方與自動產生的程式碼、資料檔,以及看起來"; +"Its %1$@ code files leave the code index." = "其中的 %1$@ 個程式碼檔案將移出索引。"; +"Its %1$@ files leave the index. Adding it back later indexes them " = "其中的 %1$@ 個檔案將移出索引。日後重新加入時,會"; +"Kept for when it's back" = "保留索引,等待重新連接"; +"Location" = "位置"; +"Looking at the folders…" = "檢查資料夾中…"; +"Looking at your code…" = "檢查程式碼中…"; +"Looking at your folders…" = "檢查資料夾中…"; +"Low Power Mode" = "低耗電模式"; +"Mac is warm" = "Mac 溫度偏高"; +"Matches %1$@" = "%1$@ 符合"; +"Matches its name" = "檔名符合"; +"Matches its text" = "文字內容符合"; +"Matches the frame at %1$@" = "%1$@ 的畫面符合"; +"Matches the picture" = "圖片內容符合"; +"Matches the sound at %1$@" = "%1$@ 的聲音符合"; +"Matches the text in it" = "其中的文字符合"; +"Matches the text on %1$@" = "%1$@ 的文字符合"; +"Matches what's heard at %1$@" = "%1$@ 的音訊內容符合"; +"Matches what's on screen" = "螢幕內容符合"; +"Matching words only until the model has downloaded (%1$@%%)" = "模型下載完成前僅比對文字(%1$@%%)"; +"Matching words only: the model isn't downloaded" = "僅比對文字:尚未下載模型"; +"Matching words; meaning search is warming up…" = "比對文字中;語意搜尋正在準備…"; +"Matching words…" = "比對文字中…"; +"Minimize" = "縮小"; +"Model" = "模型"; +"Model download: waiting for the network" = "模型下載:等待網路連線"; +"Model not downloaded" = "尚未下載模型"; +"Model ready" = "模型已就緒"; +"Moments inside audio and video" = "音訊與影片中的片段"; +"Needs %1$@ free on this disk" = "此磁碟需要 %1$@ 可用空間"; +"New Search" = "新增搜尋"; +"No access" = "沒有存取權限"; +"No code folders yet. Add the folders that hold your projects." = "尚未加入程式碼資料夾。請加入存放專案的資料夾。"; +"No code here" = "這裡沒有程式碼"; +"No folders chosen yet" = "尚未選擇資料夾"; +"No folders yet. Add the ones DigUp should search." = "尚未加入資料夾。請加入要讓 DigUp 搜尋的資料夾。"; +"No good matches" = "找不到相關結果"; +"No good matches for “%1$@”" = "找不到與「%1$@」相關的結果"; +"No good matches in your code" = "找不到相關的程式碼"; +"None. Code projects, app bundles and caches are skipped anyway (code has its own tab)." = "無。程式專案、App 與快取一律略過;程式碼請在專用分頁設定。"; +"Not connected" = "未連接"; +"Not downloaded yet" = "尚未下載"; +"Nothing to search here yet" = "目前沒有可搜尋的內容"; +"Nothing to search in the subfolders checked" = "勾選的子資料夾沒有可搜尋的內容"; +"Off unless you turn it on." = "需自行啟用,預設關閉。"; +"Once a day DigUp asks GitHub for its newest version; nothing about your files is sent. " = "DigUp 每天向 GitHub 檢查一次新版本,不會傳送你的檔案資訊。"; +"Open" = "開啟"; +"Open DigUp" = "開啟 DigUp"; +"Open DigUp from anywhere" = "隨時開啟 DigUp"; +"Open DigUp when you log in" = "登入時開啟 DigUp"; +"Open a folder to pick some of its subfolders." = "展開資料夾可選取部分子資料夾。"; +"Open at %1$@" = "從 %1$@ 開啟"; +"Open code in" = "開啟程式碼的 App"; +"Open in %1$@" = "在 %1$@ 中開啟"; +"Open the file" = "開啟檔案"; +"Open them in the DigUp window (⌘O shows everything)" = "在 DigUp 視窗中開啟(⌘O 顯示全部結果)"; +"Opens the search panel from any app." = "在任何 App 中開啟搜尋面板。"; +"PDFs" = "PDF"; +"PDFs and documents" = "PDF 與文件"; +"Part %1$@ of %2$@" = "第 %1$@ 部分,共 %2$@ 部分"; +"Paste" = "貼上"; +"Pause" = "暫停"; +"Pause Indexing" = "暫停建立索引"; +"Paused · %1$@" = "已暫停 · %1$@"; +"Paused · %1$@ code files waiting" = "已暫停 · %1$@ 個程式碼檔案待處理"; +"Pick folders, or open one to pick some of its subfolders. You can change this " = "選擇資料夾,也可展開後只選取部分子資料夾。你可以"; +"Pick the folders it should search." = "選擇要搜尋的資料夾。"; +"Pick the folders that hold your projects; open one to pick among its repos. " = "選擇存放專案的資料夾,也可展開後選取其中的儲存庫。"; +"Play" = "播放"; +"Play from %1$@" = "從 %1$@ 播放"; +"Press the new shortcut…" = "請按下新的快捷鍵…"; +"Press the shortcut in any app, describe what you're looking for, and press Return." = "在任何 App 中按下快捷鍵,描述要找的內容,再按 Return。"; +"Privacy" = "隱私"; +"Quick Look, after arrowing to a result" = "用方向鍵選取結果後,開啟「快速查看」"; +"Quit DigUp" = "結束 DigUp"; +"Reading the rest of %1$@ · %2$@%%" = "讀取 %1$@ 的剩餘內容 · %2$@%%"; +"Ready" = "已就緒"; +"Recording a new shortcut" = "等待輸入新快捷鍵"; +"Redo" = "重做"; +"Remove" = "移除"; +"Remove %1$@" = "移除 %1$@"; +"Resume" = "繼續"; +"Resume Indexing" = "繼續建立索引"; +"Say what the code does, or type a name that's in it." = "描述程式碼的功能,或輸入其中的名稱。"; +"Screenshot" = "截圖"; +"Screenshots" = "截圖"; +"Screenshots and photos" = "截圖與照片"; +"Screenshots use %1$@" = "螢幕截圖已使用 %1$@"; +"Search" = "搜尋"; +"Search for “%1$@”" = "搜尋「%1$@」"; +"Search my code" = "搜尋我的程式碼"; +"Search panel open" = "搜尋面板已開啟"; +"Search shortcut" = "搜尋快捷鍵"; +"Search this folder again" = "重新納入搜尋範圍"; +"Search your code" = "搜尋你的程式碼"; +"Search your code too?" = "也要搜尋程式碼嗎?"; +"Searching your code" = "搜尋程式碼中"; +"Searching…" = "搜尋中…"; +"Search…" = "搜尋…"; +"See what's new and update" = "查看新版內容並更新"; +"Select All" = "全選"; +"Settings (⌘,)" = "設定(⌘,)"; +"Settings…" = "設定…"; +"Shortcut %1$@, click to change" = "快捷鍵 %1$@,按一下更改"; +"Show %1$@ more" = "再顯示 %1$@ 個"; +"Show Preview" = "顯示預覽"; +"Show all %1$@ %2$@…" = "顯示全部 %1$@ 筆%1$@…"; +"Show in Finder" = "在 Finder 中顯示"; +"Show it in Finder" = "在 Finder 中顯示"; +"Show the subfolders of %1$@" = "顯示 %1$@ 的子資料夾"; +"Size on disk" = "磁碟使用量"; +"Skip" = "略過"; +"Skip a Folder…" = "略過資料夾…"; +"Skip some folders or file types" = "略過部分資料夾或檔案類型"; +"Skip these file types" = "略過這些檔案類型"; +"Skip these folders" = "略過這些資料夾"; +"So the shortcut always works." = "讓快捷鍵隨時可用。"; +"Spotlight uses %1$@" = "Spotlight 已使用 %1$@"; +"Starting…" = "啟動中…"; +"Status" = "狀態"; +"Step %1$@ of %2$@" = "第 %1$@ 步,共 %2$@ 步"; +"Still indexing (%1$@%%): some files aren't in yet" = "仍在建立索引(%1$@%%):部分檔案尚未納入"; +"Still reading code (%1$@%%): some isn't in yet" = "仍在讀取程式碼(%1$@%%):部分尚未納入"; +"Stop searching the code in “%1$@”?" = "要停止搜尋「%1$@」中的程式碼嗎?"; +"Stop searching this folder" = "停止搜尋此資料夾"; +"Stop searching “%1$@”?" = "要停止搜尋「%1$@」嗎?"; +"Storage" = "儲存空間"; +"Switching input sources uses %1$@" = "切換輸入法已使用 %1$@"; +"TRY" = "試試看"; +"That's it. It works from any app." = "就這麼簡單,在任何 App 中都能使用。"; +"The code index (%1$@) is deleted. Turning it on again reads the code again." = "將刪除程式碼索引(%1$@)。再次啟用時會重新讀取程式碼。"; +"The code index keeps your code's text on this Mac, as the main index keeps your documents'. " = "程式碼索引將程式文字儲存在這台 Mac 上,與文件索引相同。"; +"The download couldn't resume" = "無法續傳下載"; +"The download stopped early" = "下載提前中止"; +"The download was damaged twice. Try again later." = "下載內容連續兩次損毀,請稍後再試。"; +"The emoji picker uses %1$@" = "表情符號選擇器已使用 %1$@"; +"The file's default app" = "檔案的預設 App"; +"The first pass takes this long. Long PDFs and documents get their first pages in it, " = "首次建立索引所需的時間。較長的 PDF 與文件會先讀取前幾頁,"; +"The model isn't downloaded yet" = "尚未下載模型"; +"They match much less well than the results above. Press Return to " = "這些結果比上方結果的相關性低。按 Return 可"; +"Try Again" = "再試一次"; +"Try describing what's in it: what you'd see or hear, or words you remember." = "試著描述其中的畫面、聲音,或你記得的文字。"; +"Try it now: press %1$@." = "現在試試看:按下 %1$@。"; +"Try saying what the code does, or a name that's in it." = "試著描述程式碼的功能,或輸入其中的名稱。"; +"Try what you'd see or hear in it, or words you remember from it." = "試著描述其中的畫面、聲音,或你記得的文字。"; +"Turn Off" = "關閉"; +"Turn Off Code Search…" = "關閉程式碼搜尋…"; +"Turn off code search?" = "要關閉程式碼搜尋嗎?"; +"Type what you remember in the search field above: what's in it, what it looks or sounds like, or " = "在上方搜尋欄輸入你記得的內容:裡面有什麼、畫面或聲音,或是"; +"Under a second" = "不到一秒"; +"Undo" = "復原"; +"Update to DigUp %1$@…" = "更新至 DigUp %1$@…"; +"Updates install with one click." = "按一下即可安裝更新。"; +"Use ⌘, ⌥ or ⌃ with a key" = "請搭配 ⌘、⌥ 或 ⌃ 與其他按鍵"; +"Video" = "影片"; +"Waiting for a network connection" = "等待網路連線"; +"Waiting for the network…" = "等待網路連線…"; +"Weaker matches" = "較不相關的結果"; +"Welcome to DigUp" = "歡迎使用 DigUp"; +"What should DigUp search?" = "要讓 DigUp 搜尋哪些資料夾?"; +"When it's off, a folder's first pass waits for a charger. New files always go in right away." = "關閉時,首次建立資料夾索引會等到接上電源。新增檔案仍會立即納入索引。"; +"Window" = "視窗"; +"Words only: meaning search isn't available" = "僅比對文字:語意搜尋目前無法使用"; +"You're all set" = "設定完成"; +"a dog on the beach" = "沙灘上的狗"; +"a page" = "頁面"; +"a screenshot of an error message" = "錯誤訊息截圖"; +"about %1$@" = "約 %1$@"; +"about %1$@ left" = "約剩 %1$@"; +"after that it works offline, and nothing you index ever leaves your Mac." = "之後即可離線使用,索引內容不會離開你的 Mac。"; +"afterwards (about %1$@ more)." = "稍後再讀取(另需約 %1$@)。"; +"again." = "重新建立索引。"; +"and generated code, data files, and anything that looks like a key or a password." = "與自動產生的程式碼、資料檔,以及看起來像金鑰或密碼的內容。"; +"and never shows up in other searches." = "不會出現在其他搜尋結果中。"; +"and the rest afterwards, once everything else is in." = "待其他檔案完成後,再讀取剩餘內容。"; +"anytime in Settings." = "隨時在「設定」中調整。"; +"audio file" = "音訊檔案"; +"audio files" = "音訊檔案"; +"cell %1$@" = "第 %1$@ 個儲存格"; +"cells %1$@–%2$@" = "第 %1$@–%2$@ 個儲存格"; +"code file" = "程式碼檔案"; +"code files" = "程式碼檔案"; +"code is read only in the code folders (the Code tab). Keys, certificates and password files are " = "程式碼只在「程式碼」分頁所選的資料夾中讀取。金鑰、憑證與密碼檔案"; +"code: in the search panel." = "code:。"; +"code: parse the command line options" = "code: 解析命令列選項"; +"code: retry a request with backoff" = "code: 逐次延長等待時間後重試請求"; +"code: where the settings are saved" = "code: 設定儲存在哪裡"; +"document" = "文件"; +"documents" = "文件"; +"e.g. heic, txt" = "例如 heic、txt"; +"fold them away." = "收合。"; +"image" = "圖片"; +"images" = "圖片"; +"in %1$@" = "搜尋範圍:%1$@"; +"less than a minute left" = "剩餘不到一分鐘"; +"like a key or a password." = "像金鑰或密碼的內容。"; +"line %1$@" = "第 %1$@ 行"; +"lines %1$@–%2$@" = "第 %1$@–%2$@ 行"; +"long files" = "較長檔案"; +"macOS isn't letting DigUp read it." = "macOS 尚未允許 DigUp 讀取此資料夾。"; +"macOS uses %1$@ for one of its shortcuts" = "macOS 已將 %1$@ 用於系統快捷鍵"; +"macOS will ask before DigUp can read folders like Desktop, Documents and Downloads. " = "讀取「桌面」、「文件」、「下載項目」等資料夾前,macOS 會先詢問你的許可。"; +"never read, nor are hidden folders like ~/.ssh." = "以及 ~/.ssh 等隱藏資料夾一律不讀取。"; +"never shows up in other searches." = "不會出現在其他搜尋結果中。"; +"on battery" = "使用電池中"; +"opens DigUp from any app" = "在任何 App 中開啟 DigUp"; +"other searches." = "其他搜尋結果中。"; +"p. %1$@" = "第 %1$@ 頁"; +"page %1$@" = "第 %1$@ 頁"; +"partly read so far" = "已讀取部分內容"; +"results fill in as it goes." = "結果會隨進度逐漸增加。"; +"screenshot" = "截圖"; +"screenshots" = "截圖"; +"searches. You can turn it on later in Settings → Code." = "搜尋結果中。你也可以稍後在「設定 → 程式碼」中啟用。"; +"show them." = "展開。"; +"slides about the budget" = "預算相關簡報"; +"someone laughing" = "有人在笑"; +"that, only what changes is read again." = "之後只會重新讀取變更的內容。"; +"under a second" = "不到一秒"; +"video" = "影片"; +"videos" = "影片"; +"which sends nothing about your files." = "不會傳送你的檔案資訊。"; +"whiteboard notes" = "白板筆記"; +"words from it." = "其中的文字。"; +"“%1$@” is searched already" = "「%1$@」已在搜尋範圍中"; +"“%1$@” isn't searched anyway." = "「%1$@」原本就不在搜尋範圍中。"; +"“code: retry with backoff”. Code has an index of its own, and it never shows up in " = "「code: 逐次延長等待時間後重試」。程式碼使用獨立索引,不會出現在"; +"“code: retry with backoff”. It gets an index of its own and never shows up in other " = "「code: 逐次延長等待時間後重試」。它使用獨立索引,不會出現在其他"; +"“that payment error”, “the zebra”, “whiteboard from Tuesday”" = "「付款錯誤」、「斑馬」、「星期二的白板」"; +"“the clause about pets in the lease”, “flight receipt”" = "「租約裡的寵物條款」、「機票收據」"; +"“where they talk about sleep”, “the part with the drums”" = "「談到睡眠的地方」、「有鼓聲的片段」"; +"↩ %1$@ ⌘↩ Reveal ␣ Quick Look%2$@ ⌘C Copy" = "↩ %1$@ ⌘↩ 顯示位置 ␣ 快速查看%2$@ ⌘C 複製"; +"↩ %1$@%2$@ ⌘↩ Reveal ␣ Quick Look ⌘C Copy" = "↩ %1$@%2$@ ⌘↩ 顯示位置 ␣ 快速查看 ⌘C 複製"; +"⚠︎ The model download stopped" = "⚠︎ 模型下載已停止"; +"the code" = "程式碼"; +"none" = "未設定"; + +/* Added in DigUp 0.6.1. */ +" and " = " 和 "; +"%1$@ %2$@ indexed · %3$@" = "已為 %1$@ 個檔案建立索引 · %3$@"; +"%1$@ code %2$@ waiting · read after your other files" = "%1$@ 個程式碼檔案等待中 · 將在其他檔案處理完成後讀取"; +"%1$@ code files waiting · indexing from %2$@" = "%1$@ 個程式碼檔案等待中 · 將於 %2$@ 開始建立索引"; +"Choose the model files you downloaded: %1$@." = "請選取已下載的模型檔案:%1$@。"; +"Copies the addresses of the two files" = "複製這兩個檔案的下載網址"; +"Copy Links" = "複製連結"; +"Downloaded the model's two files somewhere else? Choose them here." = "已從其他地方下載模型的兩個檔案?請在這裡選取。"; +"Files are read only between these times, and what changed meanwhile is picked up when they begin. Search works all day." = "只在指定時段讀取檔案,期間發生的變更會在時段開始後處理。搜尋功能全天都可使用。"; +"From" = "從"; +"If your network blocks Hugging Face, download the two files on another network and click Use Files… to choose them. DigUp checks them before it uses them." = "如果目前的網路封鎖 Hugging Face,請透過其他網路下載這兩個檔案,再按「使用檔案…」選取。DigUp 會先驗證檔案,再開始使用。"; +"Index only at set hours" = "只在指定時段建立索引"; +"The chosen %1$@ is %2$@, not %3$@" = "選取的 %1$@ 大小為 %2$@,應為 %3$@"; +"The chosen %1$@ isn't the right file" = "選取的 %1$@ 不是正確的檔案"; +"Those aren't the model's files" = "選取的檔案不是模型檔案"; +"To" = "結束時間"; +"Use" = "使用"; +"Use Files…" = "使用檔案…"; +"indexing from %1$@" = "將於 %1$@ 開始建立索引"; +"to" = "至"; diff --git a/Tests/DigUpLocalizationTests/LocalizationTests.swift b/Tests/DigUpLocalizationTests/LocalizationTests.swift new file mode 100644 index 00000000..333621bb --- /dev/null +++ b/Tests/DigUpLocalizationTests/LocalizationTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing +@testable import DigUpLocalization + +struct LocalizationTests { + private func localizedBundle(_ language: String) throws -> Bundle { + let path = try #require(L10n.bundle.path(forResource: language, ofType: "lproj")) + return try #require(Bundle(path: path)) + } + + private func strings(_ language: String) throws -> [String: String] { + let bundle = try localizedBundle(language) + let url = try #require(bundle.url(forResource: "Localizable", withExtension: "strings")) + return try #require(PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) as? [String: String]) + } + + @Test func languagesHaveTheSameKeysAndEnglishFallsBackToItsKeys() throws { + let english = try strings("en") + let chinese = try strings("zh-Hant") + #expect(english.count > 350) + #expect(Set(english.keys) == Set(chinese.keys)) + for (key, value) in english { #expect(key == value) } + #expect(chinese.values.allSatisfy { !$0.isEmpty }) + #expect(L10n.text("missing.translation.key") == "missing.translation.key") + } + + @Test func translatedFormatsUseOnlyExistingArguments() throws { + let english = try strings("en") + let chinese = try strings("zh-Hant") + let pattern = try NSRegularExpression(pattern: #"%(\d+)\$@"#) + func arguments(_ value: String) -> Set { + Set(pattern.matches(in: value, range: NSRange(value.startIndex..., in: value)).map { + (value as NSString).substring(with: $0.range(at: 1)) + }) + } + for (key, value) in chinese { + let translatedArguments = arguments(value) + let originalArguments = arguments(english[key]!) + #expect(translatedArguments.isSubset(of: originalArguments)) + if let count = originalArguments.compactMap(Int.init).max() { + let values: [CVarArg] = (1...count).map { "⟦\($0)⟧" } + let formatted = String(format: value, arguments: values) + for argument in translatedArguments { #expect(formatted.contains("⟦\(argument)⟧")) } + } + } + } + + @Test func dynamicValuesAndLiteralPercentSurviveTranslation() throws { + let english = try localizedBundle("en") + let chinese = try localizedBundle("zh-Hant") + let key = "Indexing %1$@%% · %2$@ of %3$@" + let args: [CVarArg] = ["42", "21", "50"] + #expect(String(format: english.localizedString(forKey: key, value: nil, table: nil), arguments: args) + == "Indexing 42% · 21 of 50") + #expect(String(format: chinese.localizedString(forKey: key, value: nil, table: nil), arguments: args) + == "建立索引中 42% · 21/50") + #expect(chinese.localizedString(forKey: "Settings…", value: nil, table: nil) == "設定…") + } + + @Test func scheduledIndexingAndManualModelFilesKeepDisplayValues() throws { + let bundle = try localizedBundle("zh-Hant") + func format(_ key: String, _ args: CVarArg...) -> String { + String(format: bundle.localizedString(forKey: key, value: nil, table: nil), arguments: args) + } + #expect(format("%1$@ code files waiting · indexing from %2$@", "12", "01:00") + == "12 個程式碼檔案等待中 · 將於 01:00 開始建立索引") + #expect(format("%1$@ %2$@ indexed · %3$@", "42", "files", "將於 01:00 開始建立索引") + == "已為 42 個檔案建立索引 · 將於 01:00 開始建立索引") + #expect(format("The chosen %1$@ is %2$@, not %3$@", "model.gguf", "10 MB", "865 MB") + == "選取的 model.gguf 大小為 10 MB,應為 865 MB") + #expect(bundle.localizedString(forKey: "Index only at set hours", value: nil, table: nil) + == "只在指定時段建立索引") + #expect(bundle.localizedString(forKey: "Use Files…", value: nil, table: nil) == "使用檔案…") + } + + @Test func permissionDescriptionsAreLocalized() throws { + for language in ["en", "zh-Hant"] { + let bundle = try localizedBundle(language) + let value = bundle.localizedString(forKey: "NSDocumentsFolderUsageDescription", value: nil, table: "InfoPlist") + #expect(value.contains("DigUp")) + #expect(value != "NSDocumentsFolderUsageDescription") + } + } +} diff --git a/build.sh b/build.sh index eeeef7e5..7e09ebad 100755 --- a/build.sh +++ b/build.sh @@ -47,6 +47,14 @@ cp "$ICON" "$APP/Contents/Resources/AppIcon.icns" # over actool's plainer fall cp Resources/Acknowledgements.txt "$APP/Contents/Resources/Acknowledgements.txt" cp Resources/AppIcon/tray.png Resources/AppIcon/tray-dim.png Resources/AppIcon/tray-paused.png "$APP/Contents/Resources/" # the menubar's logo +# SwiftPM's localization bundle and main-bundle permission descriptions. +ditto "$BIN_DIR/DigUp_DigUpLocalization.bundle" "$APP/Contents/Resources/DigUp_DigUpLocalization.bundle" +for localization in Sources/DigUpLocalization/Resources/*.lproj; do + language="$(basename "$localization")" + mkdir -p "$APP/Contents/Resources/$language" + cp "$localization/InfoPlist.strings" "$APP/Contents/Resources/$language/InfoPlist.strings" +done + # Sparkle (in-app updates): the framework in Frameworks/, where the app's runpath looks. Its XPC services are for # sandboxed apps and its headers for building against it: left out. FW="$APP/Contents/Frameworks/Sparkle.framework" diff --git a/docs/localization.md b/docs/localization.md new file mode 100644 index 00000000..69f46321 --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,35 @@ +# Interface localization + +DigUp follows the language selected for the app in macOS, with English as its development language and fallback. +Traditional Chinese uses `zh-Hant`, including the system's `zh-TW` preference. + +The `DigUpLocalization` target contains `L10n` and these resources: + +``` +Sources/DigUpLocalization/Resources/ +├── en.lproj/ Localizable.strings, InfoPlist.strings +└── zh-Hant.lproj/ Localizable.strings, InfoPlist.strings +``` + +`L10n.text` looks up static interface text. `L10n.format` formats display values with positional `%1$@`, `%2$@`, etc. +The English format is also the lookup key. Pass already formatted strings (for example a count's `.formatted()` value), +and escape a literal percent as `%%` in format strings. Translations may reorder or omit an argument; Chinese need not +include an English singular/plural noun supplied by the calling code. Keep both English noun forms for existing English +plural behavior. `String(format:)` duration formats such as `%.1f h` are translated before their existing formatting call. + +To add a language, add its `.lproj` directory with both files and the same interface keys. Keep permission descriptions +consistent with the English Info.plist. Run `swift test`, build with `./build.sh`, and inspect the packaged app's UI in +both languages, including long explanatory text and strings with counts, percent signs, and file names. + +`build.sh` embeds the SwiftPM resource bundle in `Contents/Resources`. It also copies each language's `InfoPlist.strings` +into the main app bundle so macOS can localize its permission prompts. This step matters: launching from the build +folder alone does not prove that the distributed `.app` includes its translations. + +Search terms, tokenizer behavior, model prompts, file paths, stored settings keys, helper commands and diagnostics are +not localized. Sample queries shown in the UI may be translated; they are examples, not changes to search processing. +System error descriptions and third-party UI use the localization supplied by macOS or the dependency. + + +## Origin + +Adapted from [PR #34 by max0071001](https://github.com/ARahim3/DigUp/pull/34), through commit `ef69d2658fbdd5885626ddc68e5e7b12f7523fa7`, onto current main `ec28973`. This is the original author’s localization framework and translations, not newly authored work. Historical screenshots and test counts are omitted because this rebased build has not been validated on macOS. From 5ce830f6387eaff69b0c5854f1affc63526e64c6 Mon Sep 17 00:00:00 2001 From: shimon <63890274+shimon-ai-dev@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:01:37 +0800 Subject: [PATCH 2/2] Add Simplified Chinese UI and localization validation Add zh-Hans interface strings and permission descriptions alongside the adapted English and Traditional Chinese localization framework from ARahim3/DigUp#34. Cover language fallback, dynamic display values, source tables and packaged localization resources with regression checks. Preserve upstream Bundle ID, update feed/key, permissions and dependency lockfile. Keep fork-only CI outside this feature branch. --- Sources/DigUpApp/SearchWindow.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/zh-Hans.lproj/InfoPlist.strings | 5 + .../zh-Hans.lproj/Localizable.strings | 429 ++++++++++++++++++ .../zh-Hant.lproj/Localizable.strings | 4 +- .../LocalizationTests.swift | 20 +- .../SimplifiedChineseTests.swift | 73 +++ docs/localization.md | 96 +++- scripts/check-localizations.py | 122 +++++ scripts/test-localizations.py | 82 ++++ 10 files changed, 804 insertions(+), 31 deletions(-) create mode 100644 Sources/DigUpLocalization/Resources/zh-Hans.lproj/InfoPlist.strings create mode 100644 Sources/DigUpLocalization/Resources/zh-Hans.lproj/Localizable.strings create mode 100644 Tests/DigUpLocalizationTests/SimplifiedChineseTests.swift create mode 100644 scripts/check-localizations.py create mode 100644 scripts/test-localizations.py diff --git a/Sources/DigUpApp/SearchWindow.swift b/Sources/DigUpApp/SearchWindow.swift index 9821d114..abdf2f27 100644 --- a/Sources/DigUpApp/SearchWindow.swift +++ b/Sources/DigUpApp/SearchWindow.swift @@ -843,7 +843,7 @@ private struct SearchHome: View { @State private var kinds: [FileKind: Int] = [:] private static let examples: [(text: String, kind: ResultGroupKind)] = [ - (L10n.text("a screenshot of an error message"), .screenshots), ("receipt", .documents), (L10n.text("whiteboard notes"), .images), + (L10n.text("a screenshot of an error message"), .screenshots), (L10n.text("receipt"), .documents), (L10n.text("whiteboard notes"), .images), (L10n.text("someone laughing"), .audio), (L10n.text("a dog on the beach"), .video), (L10n.text("slides about the budget"), .documents), ] diff --git a/Sources/DigUpLocalization/Resources/en.lproj/Localizable.strings b/Sources/DigUpLocalization/Resources/en.lproj/Localizable.strings index 4149e595..2bbd0225 100644 --- a/Sources/DigUpLocalization/Resources/en.lproj/Localizable.strings +++ b/Sources/DigUpLocalization/Resources/en.lproj/Localizable.strings @@ -425,3 +425,5 @@ "Use Files…" = "Use Files…"; "indexing from %1$@" = "indexing from %1$@"; "to" = "to"; + +"receipt" = "receipt"; diff --git a/Sources/DigUpLocalization/Resources/zh-Hans.lproj/InfoPlist.strings b/Sources/DigUpLocalization/Resources/zh-Hans.lproj/InfoPlist.strings new file mode 100644 index 00000000..b323268e --- /dev/null +++ b/Sources/DigUpLocalization/Resources/zh-Hans.lproj/InfoPlist.strings @@ -0,0 +1,5 @@ +"NSAppleEventsUsageDescription" = "DigUp 会请 QuickTime Player 打开录音或视频,并跳到匹配搜索内容的时间点。"; +"NSDesktopFolderUsageDescription" = "DigUp 会为你选择的文件夹创建索引,让你依内容描述搜索文件。所有内容都留在这台 Mac 上。"; +"NSDocumentsFolderUsageDescription" = "DigUp 会为你选择的文件夹创建索引,让你依内容描述搜索文件。所有内容都留在这台 Mac 上。"; +"NSDownloadsFolderUsageDescription" = "DigUp 会为你选择的文件夹创建索引,让你依内容描述搜索文件。所有内容都留在这台 Mac 上。"; +"NSRemovableVolumesUsageDescription" = "DigUp 会为你选择的文件夹创建索引,让你依内容描述搜索文件。所有内容都留在这台 Mac 上。"; diff --git a/Sources/DigUpLocalization/Resources/zh-Hans.lproj/Localizable.strings b/Sources/DigUpLocalization/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..24d4003d --- /dev/null +++ b/Sources/DigUpLocalization/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,429 @@ +/* DigUp interface strings. Positional %@ arguments contain already formatted display values. */ +" (about %1$@ on this Mac)" = "(这台 Mac 约需 %1$@)"; +" (no access)" = "(没有访问权限)"; +" (not connected)" = "(未连接)"; +" + %1$@ later" = ",稍后另需 %1$@"; +" Long PDFs and documents get their first pages in that time, and the rest " = " 较长的 PDF 和文件会先读取前几页,其余内容"; +" at %1$@" = ",%1$@"; +" at line %1$@" = ",第 %1$@ 行"; +" in your code" = ",位于代码中"; +" of " = "/"; +" · %1$@ long %2$@ to finish" = " · %1$@ 个较长文件尚未完成"; +" · %1$@ waiting" = " · %1$@ 个待处理"; +" · %1$@ weaker" = " · %1$@ 条较不相关"; +" · model %1$@" = " · 模型:%1$@"; +" · paused" = " · 已暂停"; +"%.1f h" = "%.1f 小时"; +"%1$@ %2$@ indexed" = "%1$@ 个文件已创建索引"; +"%1$@ %2$@ indexed yet" = "%1$@ 个文件尚未创建索引"; +"%1$@ %2$@ waiting" = "%1$@ 个文件待处理"; +"%1$@ cells" = "%1$@ 个单元格"; +"%1$@ code %2$@ indexed" = "%1$@ 个代码文件已创建索引"; +"%1$@ code files waiting · %2$@" = "%1$@ 个代码文件待处理 · %2$@"; +"%1$@ couldn't be read" = "%1$@ 个无法读取"; +"%1$@ files to search in %2$@" = "%2$@ 中有 %1$@ 个文件可搜索"; +"%1$@ folders" = "%1$@ 个文件夹"; +"%1$@ lines" = "%1$@ 行"; +"%1$@ long %2$@" = "%1$@ 个较长文件"; +"%1$@ long %2$@ to finish" = "%1$@ 个较长文件尚未完成"; +"%1$@ min" = "%1$@ 分钟"; +"%1$@ of %2$@ files searchable" = "%2$@ 个文件中,%1$@ 个可搜索"; +"%1$@ of %2$@ indexed" = "%2$@ 个文件中,%1$@ 个已创建索引"; +"%1$@ pages" = "%1$@ 页"; +"%1$@ results" = "%1$@ 条结果"; +"%1$@ s" = "%1$@ 秒"; +"%1$@ skipped" = "%1$@ 个已跳过"; +"%1$@ to be indexed" = "%1$@,等待创建索引"; +"%1$@ waiting" = "%1$@ 个待处理"; +"%1$@ weaker %2$@" = "%1$@ 条较不相关的结果"; +"%1$@ – playing from " = "%1$@ – 播放起点:"; +"(%1$@). Until then, search finds files by name." = "(%1$@)。在此之前,可依文件名搜索。"; +"+ %1$@ later" = "稍后另需 %1$@"; +", %1$@ read so far" = ",已读取 %1$@ 页"; +", and %1$@ more for long files" = ",较长文件另需 %1$@"; +", as part of “%1$@”." = ",包含于「%1$@」。"; +"1 cell" = "1 个单元格"; +"1 line" = "1 行"; +"1 page" = "1 页"; +"1 result" = "1 条结果"; +"< 1 s" = "不到 1 秒"; +"A result opens there at the line that matched. Space shows the whole file in Quick Look." = "搜索结果会在所选应用中打开并跳到匹配的行。按空格键可用「快速查看」预览整份文件。"; +"About %1$@" = "约 %1$@"; +"About DigUp" = "关于 DigUp"; +"Add" = "添加"; +"Add Folders" = "添加文件夹"; +"Add Folders…" = "添加文件夹…"; +"Add Folder…" = "添加文件夹…"; +"Add folders to search" = "添加搜索文件夹"; +"Adds %1$@ of indexing" = "创建索引另需 %1$@"; +"Adds %1$@ of indexing and %2$@" = "创建索引另需 %1$@,使用约 %2$@"; +"All" = "全部"; +"Allow in Settings…" = "在系统设置中允许…"; +"Another app is using %1$@" = "另一个应用正在使用 %1$@"; +"At %1$@ of %2$@" = "%1$@,总长 %2$@"; +"Audio" = "音频"; +"Back" = "上一步"; +"Can't create %1$@" = "无法创建 %1$@"; +"Can't open the index" = "无法打开索引"; +"Can't save the model: %1$@" = "无法存储模型:%1$@"; +"Can't start indexing" = "无法开始创建索引"; +"Cancel" = "取消"; +"Check Now" = "立即检查"; +"Check for Updates…" = "检查更新…"; +"Check for updates automatically" = "自动检查更新"; +"Check the folders to add" = "勾选要添加的文件夹"; +"Check the folders with your code" = "勾选存放代码的文件夹"; +"Checking folders…" = "检查文件夹中…"; +"Checking the download…" = "检查下载内容中…"; +"Checking the model download…" = "检查模型下载中…"; +"Choose Another Folder…" = "选择其他文件夹…"; +"Choose Code Folders…" = "选择代码文件夹…"; +"Choose Folders to Search…" = "选择搜索文件夹…"; +"Choose Folders…" = "选择文件夹…"; +"Choose among its subfolders" = "选择子文件夹"; +"Choose at least one folder" = "请至少选择一个文件夹"; +"Choose folders for DigUp to search." = "选择要让 DigUp 搜索的文件夹。"; +"Choose folders to search" = "选择搜索文件夹"; +"Clear" = "清除"; +"Click to change" = "点击更改"; +"Click to set a shortcut" = "点击设置快捷键"; +"Close" = "关闭"; +"Code" = "代码"; +"Code Folders" = "代码文件夹"; +"Code folders" = "代码文件夹"; +"Code index" = "代码索引"; +"Code is read once the model has downloaded" = "模型下载完成后会读取代码"; +"Code search is off" = "代码搜索已关闭"; +"Code search reads source files, notebooks and a repo's own docs. It skips what git ignores, vendored " = "代码搜索会读取源代码、笔记本和代码仓库的文件。它会跳过 git 忽略的内容、第三方"; +"Code, %1$@, %2$@" = "代码,%1$@,%2$@"; +"Code, keys, certificates, hidden folders, app bundles and caches are always skipped." = "代码、密钥、证书、隐藏文件夹、应用和缓存一律跳过。"; +"Continue" = "继续"; +"Copied %1$@" = "已复制 %1$@"; +"Copy" = "复制"; +"Copy Path" = "复制路径"; +"Copy the file, to paste anywhere" = "复制文件,可粘贴到其他地方"; +"Couldn't look inside it; it can still be searched." = "无法查看内部内容,仍可纳入搜索。"; +"Cut" = "剪切"; +"Describe it. Dig it up." = "说出印象,找出文件。"; +"Describe it: “payment error screenshot”, “zebra in a video”…" = "描述你要找的内容,例如「付款错误截图」、「视频中的斑马」…"; +"Describe it: “receipt from the hardware store”, “zebra in a video”…" = "描述你要找的内容,例如「五金店收据」、「视频中的斑马」…"; +"Describe it: “zebra in a video”, “code: retry with backoff”…" = "描述你要找的内容,例如「视频中的斑马」、「code: 逐次延长等待时间后重试」…"; +"Describe the code: “retry a request with backoff”, a name in it…" = "描述代码,例如「逐次延长等待时间后重试请求」,或输入其中的名称…"; +"DigUp %1$@ is available" = "DigUp %1$@ 已推出"; +"DigUp can find code by what it does, in the folders you pick. Code gets an index of its own " = "DigUp 可依功能描述寻找所选文件夹中的代码。代码使用独立索引,"; +"DigUp can find code by what it does, in the folders you pick. Code gets an index of its own and " = "DigUp 可依功能描述寻找所选文件夹中的代码。代码使用独立索引,"; +"DigUp can find code by what it does: type code: in the search panel, as in " = "DigUp 可依功能描述寻找代码:在搜索面板输入 code:,例如"; +"DigUp is reading your folders%1$@, newest files first. You can search right away; " = "DigUp 正在读取文件夹%1$@,优先处理最新文件。你可以立即搜索;"; +"DigUp lives in the menu bar, which shows how indexing is going." = "DigUp 常驻菜单栏,并显示索引构建进度。"; +"DigUp only looks where you tell it to." = "DigUp 只搜索你指定的文件夹。"; +"DigUp only reads files: it never changes, moves or uploads them." = "DigUp 只读取文件,不会修改、移动或上传。"; +"DigUp only reads pictures, PDFs, documents (txt, md, rtf, docx, odt, html), audio and video here; " = "DigUp 在这里只读取图片、PDF、文件(txt、md、rtf、docx、odt、html)、音频和视频;"; +"DigUp only searches the folders you pick." = "DigUp 只搜索你选择的文件夹。"; +"DigUp reads a new folder once, then keeps up with what changes in it. " = "DigUp 首次读取新文件夹的完整内容,之后持续处理其中的变更。"; +"DigUp reads each once, then what changes. Code is searched on its own: type " = "DigUp 首次读取完整内容,之后只处理变更。代码独立搜索:请在搜索面板输入"; +"DigUp reads the folders you choose and keeps what it learns in its index, on this Mac. " = "DigUp 读取你选择的文件夹,并将索引存储在这台 Mac 上。"; +"DigUp starts reading your folders%1$@ once the model has downloaded " = "模型下载完成后,DigUp 会开始读取文件夹%1$@"; +"Document" = "文稿"; +"Documents" = "文稿"; +"Done" = "完成"; +"Download" = "下载"; +"Download the Model" = "下载模型"; +"Downloaded" = "已下载"; +"Downloading model · %1$@%%" = "下载模型中 · %1$@%%"; +"Downloading the model · %1$@" = "下载模型中 · %1$@"; +"Downloading the model · %1$@%%" = "下载模型中 · %1$@%%"; +"Edit" = "编辑"; +"EmbeddingGemma 2 by Google (Apache 2.0), run by llama.cpp (MIT)." = "Google 的 EmbeddingGemma 2(Apache 2.0),由 llama.cpp(MIT)运行。"; +"Esc cancels" = "按 Esc 取消"; +"Everything is indexed and searched on this Mac. DigUp downloads its AI model (865 MB) once; " = "所有索引和搜索都在这台 Mac 上进行。DigUp 只需下载一次 AI 模型(865 MB);"; +"Extensions" = "扩展名"; +"File" = "文件"; +"File types" = "文件类型"; +"Find code by what it does, or by a name in it: type code: in the search panel, as in " = "依功能描述或名称寻找代码:在搜索面板输入 code:,例如"; +"Finder's search window uses %1$@" = "访达搜索窗口已使用 %1$@"; +"First pass: about %1$@" = "首次创建索引:约 %1$@"; +"Folders" = "文件夹"; +"Folders and indexing" = "文件夹和索引"; +"Folders to search" = "搜索范围"; +"General" = "通用"; +"Get Started" = "开始使用"; +"Has your words" = "包含搜索文字"; +"Hide DigUp" = "隐藏 DigUp"; +"Hide Preview" = "隐藏预览"; +"Hide its subfolders" = "隐藏子文件夹"; +"Hide the subfolders of %1$@" = "隐藏 %1$@ 的子文件夹"; +"How long reading this code takes, once, and about how much room its index needs. After " = "首次读取代码所需的时间,以及索引的预计使用空间。"; +"Hugging Face answered %1$@" = "Hugging Face 返回状态码 %1$@"; +"IN THE SEARCH PANEL" = "在搜索面板中"; +"Image" = "图片"; +"Images" = "图片"; +"In " = "搜索范围:"; +"Index" = "索引"; +"Index on battery power" = "使用电池时创建索引"; +"Indexing %1$@%%" = "创建索引中 %1$@%%"; +"Indexing %1$@%% · %2$@ of %3$@" = "创建索引中 %1$@%% · %2$@/%3$@"; +"Indexing code %1$@%% · %2$@ of %3$@" = "创建代码索引中 %1$@%% · %2$@/%3$@"; +"Indexing paused" = "索引构建已暂停"; +"Indexing stopped" = "索引构建已停止"; +"It goes online only to download its model, once, and to check for updates (General), " = "只有首次下载模型和检查更新时才会连接(可在「通用」中设置),"; +"It skips what git ignores, vendored and generated code, data files, and anything that looks " = "它会跳过 git 忽略的文件、第三方和自动生成的代码、数据文件,以及看起来"; +"Its %1$@ code files leave the code index." = "其中的 %1$@ 个代码文件将移出索引。"; +"Its %1$@ files leave the index. Adding it back later indexes them " = "其中的 %1$@ 个文件将移出索引。日后重新添加时,会"; +"Kept for when it's back" = "保留索引,等待重新连接"; +"Location" = "位置"; +"Looking at the folders…" = "检查文件夹中…"; +"Looking at your code…" = "检查代码中…"; +"Looking at your folders…" = "检查文件夹中…"; +"Low Power Mode" = "低电量模式"; +"Mac is warm" = "Mac 温度偏高"; +"Matches %1$@" = "%1$@ 匹配"; +"Matches its name" = "文件名匹配"; +"Matches its text" = "文字内容匹配"; +"Matches the frame at %1$@" = "%1$@ 的画面匹配"; +"Matches the picture" = "图片内容匹配"; +"Matches the sound at %1$@" = "%1$@ 的声音匹配"; +"Matches the text in it" = "其中的文字匹配"; +"Matches the text on %1$@" = "%1$@ 的文字匹配"; +"Matches what's heard at %1$@" = "%1$@ 的音频内容匹配"; +"Matches what's on screen" = "屏幕内容匹配"; +"Matching words only until the model has downloaded (%1$@%%)" = "模型下载完成前仅匹配文字(%1$@%%)"; +"Matching words only: the model isn't downloaded" = "仅匹配文字:尚未下载模型"; +"Matching words; meaning search is warming up…" = "匹配文字中;语义搜索正在准备…"; +"Matching words…" = "匹配文字中…"; +"Minimize" = "最小化"; +"Model" = "模型"; +"Model download: waiting for the network" = "模型下载:等待网络连接"; +"Model not downloaded" = "尚未下载模型"; +"Model ready" = "模型已就绪"; +"Moments inside audio and video" = "音频和视频中的片段"; +"Needs %1$@ free on this disk" = "此磁盘需要 %1$@ 可用空间"; +"New Search" = "新建搜索"; +"No access" = "没有访问权限"; +"No code folders yet. Add the folders that hold your projects." = "尚未添加代码文件夹。请添加存放项目的文件夹。"; +"No code here" = "这里没有代码"; +"No folders chosen yet" = "尚未选择文件夹"; +"No folders yet. Add the ones DigUp should search." = "尚未添加文件夹。请添加要让 DigUp 搜索的文件夹。"; +"No good matches" = "找不到相关结果"; +"No good matches for “%1$@”" = "找不到和「%1$@」相关的结果"; +"No good matches in your code" = "找不到相关的代码"; +"None. Code projects, app bundles and caches are skipped anyway (code has its own tab)." = "无。代码项目、应用和缓存一律跳过;代码请在专用选项卡设置。"; +"Not connected" = "未连接"; +"Not downloaded yet" = "尚未下载"; +"Nothing to search here yet" = "目前没有可搜索的内容"; +"Nothing to search in the subfolders checked" = "勾选的子文件夹没有可搜索的内容"; +"Off unless you turn it on." = "需自行启用,默认关闭。"; +"Once a day DigUp asks GitHub for its newest version; nothing about your files is sent. " = "DigUp 每天向 GitHub 检查一次新版本,不会发送你的文件信息。"; +"Open" = "打开"; +"Open DigUp" = "打开 DigUp"; +"Open DigUp from anywhere" = "随时打开 DigUp"; +"Open DigUp when you log in" = "登录时打开 DigUp"; +"Open a folder to pick some of its subfolders." = "展开文件夹可选择部分子文件夹。"; +"Open at %1$@" = "从 %1$@ 打开"; +"Open code in" = "打开代码的应用"; +"Open in %1$@" = "在 %1$@ 中打开"; +"Open the file" = "打开文件"; +"Open them in the DigUp window (⌘O shows everything)" = "在 DigUp 窗口中打开(⌘O 显示全部结果)"; +"Opens the search panel from any app." = "在任何应用中打开搜索面板。"; +"PDFs" = "PDF"; +"PDFs and documents" = "PDF 和文稿"; +"Part %1$@ of %2$@" = "第 %1$@ 部分,共 %2$@ 部分"; +"Paste" = "粘贴"; +"Pause" = "暂停"; +"Pause Indexing" = "暂停创建索引"; +"Paused · %1$@" = "已暂停 · %1$@"; +"Paused · %1$@ code files waiting" = "已暂停 · %1$@ 个代码文件待处理"; +"Pick folders, or open one to pick some of its subfolders. You can change this " = "选择文件夹,也可展开后只选择部分子文件夹。你可以"; +"Pick the folders it should search." = "选择要搜索的文件夹。"; +"Pick the folders that hold your projects; open one to pick among its repos. " = "选择存放项目的文件夹,也可展开后选择其中的代码仓库。"; +"Play" = "播放"; +"Play from %1$@" = "从 %1$@ 播放"; +"Press the new shortcut…" = "请按下新的快捷键…"; +"Press the shortcut in any app, describe what you're looking for, and press Return." = "在任何应用中按下快捷键,描述要找的内容,再按 Return。"; +"Privacy" = "隐私"; +"Quick Look, after arrowing to a result" = "用方向键选择结果后,打开「快速查看」"; +"Quit DigUp" = "退出 DigUp"; +"Reading the rest of %1$@ · %2$@%%" = "读取 %1$@ 的剩余内容 · %2$@%%"; +"Ready" = "已就绪"; +"Recording a new shortcut" = "等待输入新快捷键"; +"Redo" = "重做"; +"Remove" = "移除"; +"Remove %1$@" = "移除 %1$@"; +"Resume" = "继续"; +"Resume Indexing" = "继续创建索引"; +"Say what the code does, or type a name that's in it." = "描述代码的功能,或输入其中的名称。"; +"Screenshot" = "截图"; +"Screenshots" = "截图"; +"Screenshots and photos" = "截图和照片"; +"Screenshots use %1$@" = "屏幕截图已使用 %1$@"; +"Search" = "搜索"; +"Search for “%1$@”" = "搜索「%1$@」"; +"Search my code" = "搜索我的代码"; +"Search panel open" = "搜索面板已打开"; +"Search shortcut" = "搜索快捷键"; +"Search this folder again" = "重新纳入搜索范围"; +"Search your code" = "搜索你的代码"; +"Search your code too?" = "也要搜索代码吗?"; +"Searching your code" = "搜索代码中"; +"Searching…" = "搜索中…"; +"Search…" = "搜索…"; +"See what's new and update" = "查看新版内容并更新"; +"Select All" = "全选"; +"Settings (⌘,)" = "设置(⌘,)"; +"Settings…" = "设置…"; +"Shortcut %1$@, click to change" = "快捷键 %1$@,点击更改"; +"Show %1$@ more" = "再显示 %1$@ 个"; +"Show Preview" = "显示预览"; +"Show all %1$@ %2$@…" = "显示全部 %1$@ 条结果…"; +"Show in Finder" = "在访达中显示"; +"Show it in Finder" = "在访达中显示"; +"Show the subfolders of %1$@" = "显示 %1$@ 的子文件夹"; +"Size on disk" = "磁盘使用量"; +"Skip" = "跳过"; +"Skip a Folder…" = "跳过文件夹…"; +"Skip some folders or file types" = "跳过部分文件夹或文件类型"; +"Skip these file types" = "跳过这些文件类型"; +"Skip these folders" = "跳过这些文件夹"; +"So the shortcut always works." = "让快捷键随时可用。"; +"Spotlight uses %1$@" = "Spotlight 已使用 %1$@"; +"Starting…" = "启动中…"; +"Status" = "状态"; +"Step %1$@ of %2$@" = "第 %1$@ 步,共 %2$@ 步"; +"Still indexing (%1$@%%): some files aren't in yet" = "仍在创建索引(%1$@%%):部分文件尚未纳入"; +"Still reading code (%1$@%%): some isn't in yet" = "仍在读取代码(%1$@%%):部分尚未纳入"; +"Stop searching the code in “%1$@”?" = "要停止搜索「%1$@」中的代码吗?"; +"Stop searching this folder" = "停止搜索此文件夹"; +"Stop searching “%1$@”?" = "要停止搜索「%1$@」吗?"; +"Storage" = "存储空间"; +"Switching input sources uses %1$@" = "切换输入法已使用 %1$@"; +"TRY" = "试试看"; +"That's it. It works from any app." = "就这么简单,在任何应用中都能使用。"; +"The code index (%1$@) is deleted. Turning it on again reads the code again." = "将删除代码索引(%1$@)。再次启用时会重新读取代码。"; +"The code index keeps your code's text on this Mac, as the main index keeps your documents'. " = "代码索引将代码文本存储在这台 Mac 上,和文件索引相同。"; +"The download couldn't resume" = "无法继续下载"; +"The download stopped early" = "下载提前中止"; +"The download was damaged twice. Try again later." = "下载内容连续两次损坏,请稍后再试。"; +"The emoji picker uses %1$@" = "表情选择器已使用 %1$@"; +"The file's default app" = "文件的默认应用"; +"The first pass takes this long. Long PDFs and documents get their first pages in it, " = "首次创建索引所需的时间。较长的 PDF 和文件会先读取前几页,"; +"The model isn't downloaded yet" = "尚未下载模型"; +"They match much less well than the results above. Press Return to " = "这些结果比上方结果的相关性低。按 Return 可"; +"Try Again" = "再试一次"; +"Try describing what's in it: what you'd see or hear, or words you remember." = "试着描述其中的画面、声音,或你记得的文字。"; +"Try it now: press %1$@." = "现在试试看:按下 %1$@。"; +"Try saying what the code does, or a name that's in it." = "试着描述代码的功能,或输入其中的名称。"; +"Try what you'd see or hear in it, or words you remember from it." = "试着描述其中的画面、声音,或你记得的文字。"; +"Turn Off" = "关闭"; +"Turn Off Code Search…" = "关闭代码搜索…"; +"Turn off code search?" = "要关闭代码搜索吗?"; +"Type what you remember in the search field above: what's in it, what it looks or sounds like, or " = "在上方搜索栏输入你记得的内容:里面有什么、画面或声音,或是"; +"Under a second" = "不到一秒"; +"Undo" = "撤销"; +"Update to DigUp %1$@…" = "更新至 DigUp %1$@…"; +"Updates install with one click." = "点击即可安装更新。"; +"Use ⌘, ⌥ or ⌃ with a key" = "请搭配 ⌘、⌥ 或 ⌃ 和其他按键"; +"Video" = "视频"; +"Waiting for a network connection" = "等待网络连接"; +"Waiting for the network…" = "等待网络连接…"; +"Weaker matches" = "较不相关的结果"; +"Welcome to DigUp" = "欢迎使用 DigUp"; +"What should DigUp search?" = "要让 DigUp 搜索哪些文件夹?"; +"When it's off, a folder's first pass waits for a charger. New files always go in right away." = "关闭时,首次创建文件夹索引会等到接上电源。新增文件仍会立即纳入索引。"; +"Window" = "窗口"; +"Words only: meaning search isn't available" = "仅匹配文字:语义搜索目前无法使用"; +"You're all set" = "设置完成"; +"a dog on the beach" = "沙滩上的狗"; +"a page" = "页面"; +"a screenshot of an error message" = "错误消息截图"; +"about %1$@" = "约 %1$@"; +"about %1$@ left" = "约剩 %1$@"; +"after that it works offline, and nothing you index ever leaves your Mac." = "之后即可离线使用,索引内容不会离开你的 Mac。"; +"afterwards (about %1$@ more)." = "稍后再读取(另需约 %1$@)。"; +"again." = "重新创建索引。"; +"and generated code, data files, and anything that looks like a key or a password." = "和自动生成的代码、数据文件,以及看起来像密钥或密码的内容。"; +"and never shows up in other searches." = "不会出现在其他搜索结果中。"; +"and the rest afterwards, once everything else is in." = "待其他文件完成后,再读取剩余内容。"; +"anytime in Settings." = "随时在「设置」中调整。"; +"audio file" = "音频文件"; +"audio files" = "音频文件"; +"cell %1$@" = "第 %1$@ 个单元格"; +"cells %1$@–%2$@" = "第 %1$@–%2$@ 个单元格"; +"code file" = "代码文件"; +"code files" = "代码文件"; +"code is read only in the code folders (the Code tab). Keys, certificates and password files are " = "代码只在「代码」选项卡所选的文件夹中读取。密钥、证书和密码文件"; +"code: in the search panel." = "code:。"; +"code: parse the command line options" = "code: 解析命令行选项"; +"code: retry a request with backoff" = "code: 逐次延长等待时间后重试请求"; +"code: where the settings are saved" = "code: 设置存储在哪里"; +"document" = "文稿"; +"documents" = "文稿"; +"e.g. heic, txt" = "例如 heic、txt"; +"fold them away." = "收起。"; +"image" = "图片"; +"images" = "图片"; +"in %1$@" = "搜索范围:%1$@"; +"less than a minute left" = "剩余不到一分钟"; +"like a key or a password." = "像密钥或密码的内容。"; +"line %1$@" = "第 %1$@ 行"; +"lines %1$@–%2$@" = "第 %1$@–%2$@ 行"; +"long files" = "较长文件"; +"macOS isn't letting DigUp read it." = "macOS 尚未允许 DigUp 读取此文件夹。"; +"macOS uses %1$@ for one of its shortcuts" = "macOS 已将 %1$@ 用于系统快捷键"; +"macOS will ask before DigUp can read folders like Desktop, Documents and Downloads. " = "读取「桌面」、「文稿」、「下载」等文件夹前,macOS 会先询问你的许可。"; +"never read, nor are hidden folders like ~/.ssh." = "以及 ~/.ssh 等隐藏文件夹一律不读取。"; +"never shows up in other searches." = "不会出现在其他搜索结果中。"; +"on battery" = "使用电池中"; +"opens DigUp from any app" = "在任何应用中打开 DigUp"; +"other searches." = "其他搜索结果中。"; +"p. %1$@" = "第 %1$@ 页"; +"page %1$@" = "第 %1$@ 页"; +"partly read so far" = "已读取部分内容"; +"results fill in as it goes." = "结果会随进度逐渐增加。"; +"screenshot" = "截图"; +"screenshots" = "截图"; +"searches. You can turn it on later in Settings → Code." = "搜索结果中。你也可以稍后在「设置 → 代码」中启用。"; +"show them." = "展开。"; +"slides about the budget" = "预算相关演示文稿"; +"someone laughing" = "有人在笑"; +"that, only what changes is read again." = "之后只会重新读取变更的内容。"; +"under a second" = "不到一秒"; +"video" = "视频"; +"videos" = "视频"; +"which sends nothing about your files." = "不会发送你的文件信息。"; +"whiteboard notes" = "白板笔记"; +"words from it." = "其中的文字。"; +"“%1$@” is searched already" = "「%1$@」已在搜索范围中"; +"“%1$@” isn't searched anyway." = "「%1$@」原本就不在搜索范围中。"; +"“code: retry with backoff”. Code has an index of its own, and it never shows up in " = "「code: 逐次延长等待时间后重试」。代码使用独立索引,不会出现在"; +"“code: retry with backoff”. It gets an index of its own and never shows up in other " = "「code: 逐次延长等待时间后重试」。它使用独立索引,不会出现在其他"; +"“that payment error”, “the zebra”, “whiteboard from Tuesday”" = "「付款错误」、「斑马」、「星期二的白板」"; +"“the clause about pets in the lease”, “flight receipt”" = "「租约里的宠物条款」、「机票收据」"; +"“where they talk about sleep”, “the part with the drums”" = "「谈到睡眠的地方」、「有鼓声的片段」"; +"↩ %1$@ ⌘↩ Reveal ␣ Quick Look%2$@ ⌘C Copy" = "↩ %1$@ ⌘↩ 显示位置 ␣ 快速查看%2$@ ⌘C 复制"; +"↩ %1$@%2$@ ⌘↩ Reveal ␣ Quick Look ⌘C Copy" = "↩ %1$@%2$@ ⌘↩ 显示位置 ␣ 快速查看 ⌘C 复制"; +"⚠︎ The model download stopped" = "⚠︎ 模型下载已停止"; +"the code" = "代码"; +"none" = "未设置"; + +/* Added in DigUp 0.6.1. */ +" and " = " 和 "; +"%1$@ %2$@ indexed · %3$@" = "已为 %1$@ 个文件创建索引 · %3$@"; +"%1$@ code %2$@ waiting · read after your other files" = "%1$@ 个代码文件等待中 · 将在其他文件处理完成后读取"; +"%1$@ code files waiting · indexing from %2$@" = "%1$@ 个代码文件等待中 · 将于 %2$@ 开始创建索引"; +"Choose the model files you downloaded: %1$@." = "请选择已下载的模型文件:%1$@。"; +"Copies the addresses of the two files" = "复制这两个文件的下载网址"; +"Copy Links" = "复制链接"; +"Downloaded the model's two files somewhere else? Choose them here." = "已从其他地方下载模型的两个文件?请在这里选择。"; +"Files are read only between these times, and what changed meanwhile is picked up when they begin. Search works all day." = "只在指定时段读取文件,期间发生的变更会在时段开始后处理。搜索功能全天都可使用。"; +"From" = "开始时间"; +"If your network blocks Hugging Face, download the two files on another network and click Use Files… to choose them. DigUp checks them before it uses them." = "如果目前的网络屏蔽 Hugging Face,请通过其他网络下载这两个文件,再按「使用文件…」选择。DigUp 会先验证文件,再开始使用。"; +"Index only at set hours" = "只在指定时段创建索引"; +"The chosen %1$@ is %2$@, not %3$@" = "选择的 %1$@ 大小为 %2$@,应为 %3$@"; +"The chosen %1$@ isn't the right file" = "选择的 %1$@ 不是正确的文件"; +"Those aren't the model's files" = "选择的文件不是模型文件"; +"To" = "结束时间"; +"Use" = "使用"; +"Use Files…" = "使用文件…"; +"indexing from %1$@" = "将于 %1$@ 开始创建索引"; +"to" = "至"; + +"receipt" = "收据"; diff --git a/Sources/DigUpLocalization/Resources/zh-Hant.lproj/Localizable.strings b/Sources/DigUpLocalization/Resources/zh-Hant.lproj/Localizable.strings index 70eed817..be17e5e5 100644 --- a/Sources/DigUpLocalization/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/DigUpLocalization/Resources/zh-Hant.lproj/Localizable.strings @@ -273,7 +273,7 @@ "Shortcut %1$@, click to change" = "快捷鍵 %1$@,按一下更改"; "Show %1$@ more" = "再顯示 %1$@ 個"; "Show Preview" = "顯示預覽"; -"Show all %1$@ %2$@…" = "顯示全部 %1$@ 筆%1$@…"; +"Show all %1$@ %2$@…" = "顯示全部 %1$@ 筆結果…"; "Show in Finder" = "在 Finder 中顯示"; "Show it in Finder" = "在 Finder 中顯示"; "Show the subfolders of %1$@" = "顯示 %1$@ 的子資料夾"; @@ -425,3 +425,5 @@ "Use Files…" = "使用檔案…"; "indexing from %1$@" = "將於 %1$@ 開始建立索引"; "to" = "至"; + +"receipt" = "收據"; diff --git a/Tests/DigUpLocalizationTests/LocalizationTests.swift b/Tests/DigUpLocalizationTests/LocalizationTests.swift index 333621bb..ebbc84f3 100644 --- a/Tests/DigUpLocalizationTests/LocalizationTests.swift +++ b/Tests/DigUpLocalizationTests/LocalizationTests.swift @@ -4,7 +4,9 @@ import Testing struct LocalizationTests { private func localizedBundle(_ language: String) throws -> Bundle { - let path = try #require(L10n.bundle.path(forResource: language, ofType: "lproj")) + // SwiftPM normalizes locale directories to lowercase on case-sensitive platforms. + let path = try #require(L10n.bundle.path(forResource: language, ofType: "lproj") + ?? L10n.bundle.path(forResource: language.lowercased(), ofType: "lproj")) return try #require(Bundle(path: path)) } @@ -24,9 +26,10 @@ struct LocalizationTests { #expect(L10n.text("missing.translation.key") == "missing.translation.key") } - @Test func translatedFormatsUseOnlyExistingArguments() throws { + @Test(arguments: ["zh-Hant", "zh-Hans"]) + func translatedFormatsUseOnlyExistingArguments(language: String) throws { let english = try strings("en") - let chinese = try strings("zh-Hant") + let chinese = try strings(language) let pattern = try NSRegularExpression(pattern: #"%(\d+)\$@"#) func arguments(_ value: String) -> Set { Set(pattern.matches(in: value, range: NSRange(value.startIndex..., in: value)).map { @@ -37,6 +40,13 @@ struct LocalizationTests { let translatedArguments = arguments(value) let originalArguments = arguments(english[key]!) #expect(translatedArguments.isSubset(of: originalArguments)) + #if !os(macOS) + // swift-corelibs-foundation does not preserve positional gaps (e.g. + // %1$@ + %3$@ consumes argument 2). The app is macOS-only; keep its + // actual formatting assertion on Darwin, not a different formatter. + if let highest = translatedArguments.compactMap(Int.init).max(), + translatedArguments != Set((1...highest).map(String.init)) { continue } + #endif if let count = originalArguments.compactMap(Int.init).max() { let values: [CVarArg] = (1...count).map { "⟦\($0)⟧" } let formatted = String(format: value, arguments: values) @@ -64,8 +74,10 @@ struct LocalizationTests { } #expect(format("%1$@ code files waiting · indexing from %2$@", "12", "01:00") == "12 個程式碼檔案等待中 · 將於 01:00 開始建立索引") + #if os(macOS) #expect(format("%1$@ %2$@ indexed · %3$@", "42", "files", "將於 01:00 開始建立索引") == "已為 42 個檔案建立索引 · 將於 01:00 開始建立索引") + #endif #expect(format("The chosen %1$@ is %2$@, not %3$@", "model.gguf", "10 MB", "865 MB") == "選取的 model.gguf 大小為 10 MB,應為 865 MB") #expect(bundle.localizedString(forKey: "Index only at set hours", value: nil, table: nil) @@ -74,7 +86,7 @@ struct LocalizationTests { } @Test func permissionDescriptionsAreLocalized() throws { - for language in ["en", "zh-Hant"] { + for language in ["en", "zh-Hant", "zh-Hans"] { let bundle = try localizedBundle(language) let value = bundle.localizedString(forKey: "NSDocumentsFolderUsageDescription", value: nil, table: "InfoPlist") #expect(value.contains("DigUp")) diff --git a/Tests/DigUpLocalizationTests/SimplifiedChineseTests.swift b/Tests/DigUpLocalizationTests/SimplifiedChineseTests.swift new file mode 100644 index 00000000..7c03575b --- /dev/null +++ b/Tests/DigUpLocalizationTests/SimplifiedChineseTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing +@testable import DigUpLocalization + +struct SimplifiedChineseTests { + private func bundle(_ language: String) throws -> Bundle { + // SwiftPM normalizes locale directories to lowercase on case-sensitive platforms. + let path = try #require(L10n.bundle.path(forResource: language, ofType: "lproj") + ?? L10n.bundle.path(forResource: language.lowercased(), ofType: "lproj")) + return try #require(Bundle(path: path)) + } + + private func strings(_ language: String, table: String = "Localizable") throws -> [String: String] { + let url = try #require(try bundle(language).url(forResource: table, withExtension: "strings")) + return try #require(PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) + as? [String: String]) + } + + @Test func simplifiedChineseHasEveryInterfaceAndPermissionKey() throws { + for table in ["Localizable", "InfoPlist"] { + let english = try strings("en", table: table) + let simplified = try strings("zh-Hans", table: table) + #expect(Set(english.keys) == Set(simplified.keys)) + #expect(simplified.values.allSatisfy { !$0.isEmpty }) + } + let permissions = try strings("zh-Hans", table: "InfoPlist") + #expect(permissions.count == 5) + #expect(permissions.values.allSatisfy { $0.contains("DigUp") }) + } + + // This is a macOS UI contract. swift-corelibs-foundation does not implement + // Darwin’s locale negotiation, so a Linux pass must not claim to validate it. + #if os(macOS) + @Test func chineseRegionsSelectTheCorrectScriptAndUnsupportedLanguagesUseEnglish() { + let available = ["en", "zh-Hans", "zh-Hant"] + for preference in ["zh-Hans", "zh-CN", "zh-SG"] { + #expect(Bundle.preferredLocalizations(from: available, forPreferences: [preference]).first == "zh-Hans") + } + for preference in ["zh-Hant", "zh-TW", "zh-HK"] { + #expect(Bundle.preferredLocalizations(from: available, forPreferences: [preference]).first == "zh-Hant") + } + #expect(Bundle.preferredLocalizations(from: available, forPreferences: ["fr-FR"]).first == "en") + } + + #endif + + @Test func fallbackAndDynamicDisplayValuesArePreserved() throws { + let simplified = try bundle("zh-Hans") + func format(_ key: String, _ args: CVarArg...) -> String { + String(format: simplified.localizedString(forKey: key, value: nil, table: "Localizable"), arguments: args) + } + #expect(simplified.localizedString(forKey: "Settings…", value: nil, table: nil) == "设置…") + #expect(simplified.localizedString(forKey: "missing.translation.key", value: nil, table: nil) + == "missing.translation.key") + #expect(format("Indexing %1$@%% · %2$@ of %3$@", "42", "21", "50") + == "创建索引中 42% · 21/50") + #expect(format("Show all %1$@ %2$@…", "42", "results") == "显示全部 42 条结果…") + #expect(format("The chosen %1$@ is %2$@, not %3$@", "模型 % 文件.gguf", "10 MB", "865 MB") + == "选择的 模型 % 文件.gguf 大小为 10 MB,应为 865 MB") + #expect(format("%1$@ code files waiting · indexing from %2$@", "12", "01:00") + == "12 个代码文件等待中 · 将于 01:00 开始创建索引") + #if os(macOS) + #expect(format("%1$@ %2$@ indexed · %3$@", "42", "files", "将于 01:00 开始创建索引") + == "已为 42 个文件创建索引 · 将于 01:00 开始创建索引") + #endif + #expect(format("%.1f h", 1.5) == "1.5 小时") + let traditional = try bundle("zh-Hant") + #expect(String(format: traditional.localizedString(forKey: "Show all %1$@ %2$@…", value: nil, table: nil), + arguments: ["42", "results"]) == "顯示全部 42 筆結果…") + let english = try bundle("en") + #expect(english.localizedString(forKey: "Settings…", value: nil, table: nil) == "Settings…") + } +} diff --git a/docs/localization.md b/docs/localization.md index 69f46321..f0778d7e 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -1,35 +1,81 @@ -# Interface localization +# macOS interface localization -DigUp follows the language selected for the app in macOS, with English as its development language and fallback. -Traditional Chinese uses `zh-Hant`, including the system's `zh-TW` preference. +DigUp follows the language selected for the app in macOS. English is its development +language and fallback. Simplified Chinese uses `zh-Hans` (including `zh-CN` and +`zh-SG`); Traditional Chinese uses `zh-Hant` (including `zh-TW` and `zh-HK`). This +change localizes the native macOS UI only, not the Windows/Linux Tauri shell or CLI. -The `DigUpLocalization` target contains `L10n` and these resources: +## Framework origin and integration -``` -Sources/DigUpLocalization/Resources/ -├── en.lproj/ Localizable.strings, InfoPlist.strings -└── zh-Hant.lproj/ Localizable.strings, InfoPlist.strings -``` +The localization framework, English/Traditional Chinese tables and native UI +call-site changes are adapted from [PR #34 by max0071001](https://github.com/ARahim3/DigUp/pull/34), +through commit `ef69d2658fbdd5885626ddc68e5e7b12f7523fa7`. Credit for that work belongs +to its original author. The Simplified Chinese table follows those same keys and +adapts the Traditional Chinese translations with mainland Chinese UI terminology. +The `Show all` Traditional Chinese string also corrects a duplicated count. The +previously untranslated `receipt` example is localized in all three languages. + +Main does not yet contain that framework. A main-based proposal includes its +localization-only delta, ported onto `ec28973`; it overlaps with and depends on +agreement about #34. If #34 merges first, only the language/test additions and the +small count correction should be retained and rebased. No separate runtime or +fork-specific app identity is introduced. + +## Resources and formatting + +`Sources/DigUpLocalization/Resources/{en,zh-Hans,zh-Hant}.lproj` each contain +`Localizable.strings` (425 keys) and `InfoPlist.strings` (five permission descriptions). + +`L10n.text` looks up static interface text. `L10n.format` formats display values with +positional `%1$@`, `%2$@`, etc. The English format is also the lookup key. Pass +already formatted strings, for example a count's `.formatted()` value, and escape +literal percent signs as `%%`. Chinese may omit an English singular/plural noun +argument, but must preserve counts, paths, times and other display values. Numeric +duration formats such as `%.1f h` keep their original specifiers. -`L10n.text` looks up static interface text. `L10n.format` formats display values with positional `%1$@`, `%2$@`, etc. -The English format is also the lookup key. Pass already formatted strings (for example a count's `.formatted()` value), -and escape a literal percent as `%%` in format strings. Translations may reorder or omit an argument; Chinese need not -include an English singular/plural noun supplied by the calling code. Keep both English noun forms for existing English -plural behavior. `String(format:)` duration formats such as `%.1f h` are translated before their existing formatting call. +Search terms, tokenizer behavior, model prompts, file paths, stored settings keys, +helper commands and diagnostics are unchanged. Translated sample queries are UI +examples only. System errors and third-party UI retain their own localization. -To add a language, add its `.lproj` directory with both files and the same interface keys. Keep permission descriptions -consistent with the English Info.plist. Run `swift test`, build with `./build.sh`, and inspect the packaged app's UI in -both languages, including long explanatory text and strings with counts, percent signs, and file names. +`build.sh` embeds `DigUp_DigUpLocalization.bundle` in `Contents/Resources` and copies +each locale's `InfoPlist.strings` into the main app bundle for macOS permission +prompts. App name, bundle ID, index location and update feed stay production defaults. -`build.sh` embeds the SwiftPM resource bundle in `Contents/Resources`. It also copies each language's `InfoPlist.strings` -into the main app bundle so macOS can localize its permission prompts. This step matters: launching from the build -folder alone does not prove that the distributed `.app` includes its translations. +## Checks -Search terms, tokenizer behavior, model prompts, file paths, stored settings keys, helper commands and diagnostics are -not localized. Sample queries shown in the UI may be translated; they are examples, not changes to search processing. -System error descriptions and third-party UI use the localization supplied by macOS or the dependency. +Run dependency-free source and packaging-command fixture checks: +``` +python3 scripts/check-localizations.py +python3 scripts/test-localizations.py +``` + +The fixture uses a mock SwiftPM bundle and runs the actual resource-copy block from +`build.sh`; it is not a real macOS build. Source validation checks unique/nonempty +keys, exact language-key parity, positional argument types/multiplicity, literal +percent signs, permission parity and literal UI lookup coverage. Swift tests also +exercise key fallback and dynamic display values. Region negotiation and runtime +formatting with positional gaps (such as `%1$@` followed by `%3$@`) are asserted on +macOS only: Linux swift-corelibs-foundation does not implement Darwin’s language +negotiation and consumes the wrong argument for positional gaps. Static format +validation still covers every resource on Linux; this is not a claim of macOS UI +validation. Test resource lookup accepts SwiftPM’s lowercase locale directories on +case-sensitive filesystems. -## Origin +Before publishing a release, on macOS with the documented build dependencies: + +``` +swift test +./build.sh +python3 scripts/check-localizations.py --app build.noindex/DigUp.app +codesign --verify --deep --strict build.noindex/DigUp.app +``` -Adapted from [PR #34 by max0071001](https://github.com/ARahim3/DigUp/pull/34), through commit `ef69d2658fbdd5885626ddc68e5e7b12f7523fa7`, onto current main `ec28973`. This is the original author’s localization framework and translations, not newly authored work. Historical screenshots and test counts are omitted because this rebased build has not been validated on macOS. +Inspect the packaged app in English, `zh-CN`, `zh-Hans`, `zh-TW` and an unsupported +language such as French. Cover onboarding; search and no results; menus; all +settings tabs; code search; scheduled indexing; paused/progress states; manual +model selection and download errors; and long names/counts/percent signs. Check +Close/Back/Cancel and reopening flows, truncation and accessibility labels. Verify +permission descriptions in packaged resources and, in an appropriate test account, +live macOS permission prompts. Do not reuse historical #34 screenshots or test +counts as evidence that this rebased Simplified Chinese build passed. diff --git a/scripts/check-localizations.py b/scripts/check-localizations.py new file mode 100644 index 00000000..e20483b1 --- /dev/null +++ b/scripts/check-localizations.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Validate localization source tables, or a built macOS app (--app PATH). + +No third-party modules. This is a resource/packaging check, not a Swift build or UI test. +""" +import argparse +from collections import Counter +import json +from pathlib import Path +import plistlib +import re + +ROOT = Path(__file__).resolve().parents[1] +RESOURCES = ROOT / 'Sources/DigUpLocalization/Resources' +LANGUAGES = ('en', 'zh-Hans', 'zh-Hant') +ENTRY = re.compile(r'\s*("(?:\\.|[^"\\])*")\s*=\s*("(?:\\.|[^"\\])*")\s*;') +FORMAT = re.compile(r'%(?:(\d+)\$)?([-+ #0]*\d*(?:\.\d+)?)(@|[diuoxXfFeEgGcCsSp])|%%') + + +def table(path): + data = path.read_bytes() + if data.startswith((b'bplist', b'