diff --git a/Package.swift b/Package.swift index 45cf233d..970de1f5 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 54a894f9..bada8a43 100644 --- a/Sources/DigUpApp/StatusMenu.swift +++ b/Sources/DigUpApp/StatusMenu.swift @@ -1,3 +1,4 @@ +import DigUpLocalization import AppKit import SwiftUI @@ -132,39 +133,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). @@ -216,13 +217,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..6bea0a09 --- /dev/null +++ b/Tests/DigUpLocalizationTests/LocalizationTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing +@testable import DigUpLocalization + +struct LocalizationTests { + @Test func taiwanLanguageSelectsTraditionalChineseAndUnsupportedLanguagesFallBack() throws { + let available = ["en", "zh-Hant"] + for preference in ["zh-TW", "zh-Hant", "zh-Hant-TW"] { + let selected = try #require(Bundle.preferredLocalizations(from: available, forPreferences: [preference]).first) + #expect(selected == "zh-Hant", "preference: \(preference)") + #expect(try localizedBundle(selected).localizedString(forKey: "Settings…", value: nil, table: nil) == "設定…") + } + let fallback = try #require(Bundle.preferredLocalizations(from: available, forPreferences: ["fr"]).first) + #expect(fallback == "en") + #expect(try localizedBundle(fallback).localizedString(forKey: "Settings…", value: nil, table: nil) == "Settings…") + } + + 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 a4e94ad9..95b4080f 100755 --- a/build.sh +++ b/build.sh @@ -46,6 +46,14 @@ xcrun actool --compile "$APP/Contents/Resources" --platform macosx --target-devi cp "$ICON" "$APP/Contents/Resources/AppIcon.icns" # over actool's plainer fallback: ours has simplified 16 and 32 px cp Resources/Acknowledgements.txt "$APP/Contents/Resources/Acknowledgements.txt" +# 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..53adc6e3 --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,62 @@ +# 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. + +## Example: the same build in two languages + +| English | Traditional Chinese | +| --- | --- | +| ![English General settings](localization/en-general.png) | ![Traditional Chinese General settings](localization/zh-Hant-general.png) | + + +## DigUp 0.6.1 coverage + +The localized UI includes indexing hours, the next indexing time, code waiting for other files, and manual model-file +selection (including file-size/check errors). The same build continues to use English for unsupported languages. + +| English indexing hours | Traditional Chinese indexing hours | +| --- | --- | +| ![English indexing hours](localization/en-folders.png) | ![Traditional Chinese indexing hours](localization/zh-Hant-folders.png) | + +Validation on macOS: 97 tests passed (86 DigUpKit, 6 LlamaRuntime including reference vectors, 5 localization), +`./build.sh` and strict deep signature verification passed. Fourteen isolated packaged-app runs checked onboarding, +General/Folders/Code/Storage settings and search in English and `zh-TW`, plus `zh-Hant` scheduling and unsupported +French fallback. Permission descriptions were verified as packaged resources; live macOS permission dialogs were +not triggered. Helper/system error messages use their existing source language when they have no UI translation. + +## Independent regression coverage (2026-10-20) + +The localization tests also check that `zh-TW`, `zh-Hant`, and `zh-Hant-TW` select the Traditional Chinese resources, +and that an unsupported French preference selects the English resources and wording. These are resource-selection +tests, not a new packaged-app UI run. + +PR #112 already proposes the repeated Traditional Chinese result-count correction, receipt example localization, +and localization checker scripts. Those changes remain in #112 rather than being copied into this PR update. +Han filename and body search changes remain outside this localization PR. diff --git a/docs/localization/en-folders.png b/docs/localization/en-folders.png new file mode 100644 index 00000000..9ef07445 Binary files /dev/null and b/docs/localization/en-folders.png differ diff --git a/docs/localization/en-general.png b/docs/localization/en-general.png new file mode 100644 index 00000000..cd6af21d Binary files /dev/null and b/docs/localization/en-general.png differ diff --git a/docs/localization/zh-Hant-folders.png b/docs/localization/zh-Hant-folders.png new file mode 100644 index 00000000..dd859bda Binary files /dev/null and b/docs/localization/zh-Hant-folders.png differ diff --git a/docs/localization/zh-Hant-general.png b/docs/localization/zh-Hant-general.png new file mode 100644 index 00000000..4ab37c34 Binary files /dev/null and b/docs/localization/zh-Hant-general.png differ