From 2921316da17e9c4c1d5608dbcabe0adcc7dd367d Mon Sep 17 00:00:00 2001 From: atacan Date: Sat, 21 Feb 2026 10:50:52 +0100 Subject: [PATCH 01/11] empty target removal --- .vscode/launch.json | 28 ++++++----- Package.resolved | 50 +++++++++---------- Package.swift | 11 ---- .../StringCatalogKit/StringCatalogKit.swift | 2 - .../StringCatalogTranslator.swift | 2 +- .../StringCatalogKitTests.swift | 13 ----- 6 files changed, 42 insertions(+), 64 deletions(-) delete mode 100644 Sources/StringCatalogKit/StringCatalogKit.swift delete mode 100644 Tests/StringCatalogKitTests/StringCatalogKitTests.swift diff --git a/.vscode/launch.json b/.vscode/launch.json index eb54041..2fc6c52 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,7 +1,7 @@ { "configurations": [ { - "type": "lldb", + "type": "swift", "request": "launch", "sourceLanguages": [ "swift" @@ -9,11 +9,12 @@ "args": [], "cwd": "${workspaceFolder:StringCatalogKit}", "name": "Debug _Playground", - "program": "${workspaceFolder:StringCatalogKit}/.build/debug/_Playground", - "preLaunchTask": "swift: Build Debug _Playground" + "preLaunchTask": "swift: Build Debug _Playground", + "target": "_Playground", + "configuration": "debug" }, { - "type": "lldb", + "type": "swift", "request": "launch", "sourceLanguages": [ "swift" @@ -21,11 +22,12 @@ "args": [], "cwd": "${workspaceFolder:StringCatalogKit}", "name": "Release _Playground", - "program": "${workspaceFolder:StringCatalogKit}/.build/release/_Playground", - "preLaunchTask": "swift: Build Release _Playground" + "preLaunchTask": "swift: Build Release _Playground", + "target": "_Playground", + "configuration": "release" }, { - "type": "lldb", + "type": "swift", "request": "launch", "sourceLanguages": [ "swift" @@ -33,11 +35,12 @@ "args": [], "cwd": "${workspaceFolder:StringCatalogKit}", "name": "Debug StringCatalogTranslatorRun", - "program": "${workspaceFolder:StringCatalogKit}/.build/debug/StringCatalogTranslatorRun", - "preLaunchTask": "swift: Build Debug StringCatalogTranslatorRun" + "preLaunchTask": "swift: Build Debug StringCatalogTranslatorRun", + "target": "StringCatalogTranslatorRun", + "configuration": "debug" }, { - "type": "lldb", + "type": "swift", "request": "launch", "sourceLanguages": [ "swift" @@ -45,8 +48,9 @@ "args": [], "cwd": "${workspaceFolder:StringCatalogKit}", "name": "Release StringCatalogTranslatorRun", - "program": "${workspaceFolder:StringCatalogKit}/.build/release/StringCatalogTranslatorRun", - "preLaunchTask": "swift: Build Release StringCatalogTranslatorRun" + "preLaunchTask": "swift: Build Release StringCatalogTranslatorRun", + "target": "StringCatalogTranslatorRun", + "configuration": "release" } ] } \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index 2db537f..5b0e1e1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", - "version" : "1.0.0" + "revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6", + "version" : "1.1.0" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MacPaw/OpenAI", "state" : { - "revision" : "843e087929aa806adb611dbca93f9a4a7f28be04", - "version" : "0.3.0" + "revision" : "28884fcfb15946b119b6eb3498e5a900c1d8d595", + "version" : "0.4.7" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", - "version" : "1.0.2" + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", - "version" : "1.1.3" + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", - "version" : "1.1.0" + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", - "version" : "1.3.0" + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "d3a5af3038a09add4d7682f66555d6212058a3c0", - "version" : "1.2.2" + "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", + "version" : "1.10.0" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types", "state" : { - "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", - "version" : "1.3.0" + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" } }, { @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-runtime", "state" : { - "revision" : "26e8ae3515d1ff3607e924ac96fc0094775f55e8", - "version" : "1.5.0" + "revision" : "7cdf33371bf89b23b9cf4fd3ce8d3c825c28fbe8", + "version" : "1.9.0" } }, { @@ -86,17 +86,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-urlsession", "state" : { - "revision" : "9bf4c712ad7989d6a91dbe68748b8829a50837e4", - "version" : "1.0.2" + "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", + "version" : "1.2.0" } }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax", + "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "fa8f95c2d536d6620cc2f504ebe8a6167c9fc2dd", - "version" : "510.0.1" + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" } }, { @@ -104,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "b13b1d1a8e787a5ffc71ac19dcaf52183ab27ba2", - "version" : "1.1.1" + "revision" : "31073495cae9caf243c440eac94b3ab067e3d7bc", + "version" : "1.8.0" } } ], diff --git a/Package.swift b/Package.swift index 9350da3..6b4d857 100644 --- a/Package.swift +++ b/Package.swift @@ -7,8 +7,6 @@ let package = Package( name: "StringCatalogKit", platforms: [.macOS(.v14)], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. - .library(name: "StringCatalogKit", targets: ["StringCatalogKit"]), .library(name: "StringCatalog", targets: ["StringCatalog"]), .library(name: "StringCatalogTranslator", targets: ["StringCatalogTranslator"]), ], @@ -21,11 +19,6 @@ let package = Package( .package(path: "../OpenAIDependency"), ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. - .target( - name: "StringCatalogKit" - ), .target(name: "StringCatalog"), .target( name: "StringCatalogTranslator", @@ -48,10 +41,6 @@ let package = Package( .executableTarget(name: "StringCatalogTranslatorRun", dependencies: [.StringCatalogTranslator] ), - .testTarget( - name: "StringCatalogKitTests", - dependencies: ["StringCatalogKit"] - ), // .executableTarget( name: "_Playground", diff --git a/Sources/StringCatalogKit/StringCatalogKit.swift b/Sources/StringCatalogKit/StringCatalogKit.swift deleted file mode 100644 index 08b22b8..0000000 --- a/Sources/StringCatalogKit/StringCatalogKit.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/Sources/StringCatalogTranslator/StringCatalogTranslator.swift b/Sources/StringCatalogTranslator/StringCatalogTranslator.swift index 5607068..bfbc247 100644 --- a/Sources/StringCatalogTranslator/StringCatalogTranslator.swift +++ b/Sources/StringCatalogTranslator/StringCatalogTranslator.swift @@ -73,7 +73,7 @@ public struct StringCatalogTranslator { } extension StringCatalogTranslator: DependencyKey { - public static var liveValue: Self = { + public static let liveValue: Self = { @Dependency(\.deepLURLSession) var deepLURLSession @Dependency(\.openAI) var openAi diff --git a/Tests/StringCatalogKitTests/StringCatalogKitTests.swift b/Tests/StringCatalogKitTests/StringCatalogKitTests.swift deleted file mode 100644 index 70cc6af..0000000 --- a/Tests/StringCatalogKitTests/StringCatalogKitTests.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest - -@testable import StringCatalogKit - -final class StringCatalogKitTests: XCTestCase { - func testExample() throws { - // XCTest Documentation - // https://developer.apple.com/documentation/xctest - - // Defining Test Cases and Test Methods - // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods - } -} From 3aaf0e813f7a4b9b195a50d2ca0ad214f614cb74 Mon Sep 17 00:00:00 2001 From: atacan Date: Sat, 21 Feb 2026 12:10:38 +0100 Subject: [PATCH 02/11] implemented the plan --- Package.resolved | 113 ----- Package.swift | 59 +-- README.md | 57 +++ .../CatalogFileTranslationService.swift | 117 +++++ .../CatalogTranslationEngine.swift | 436 +++++++++++++++++ Sources/CatalogTranslation/Types.swift | 161 ++++++ .../LLMExampleTranslator.swift | 69 +++ Sources/StringCatalog/StringCatalog.swift | 10 +- Sources/StringCatalog/Types/StringEntry.swift | 50 +- .../Types/StringExtractionState.swift | 2 +- .../StringCatalog/Types/StringLanguage.swift | 78 ++- .../Types/StringLocalization.swift | 3 +- .../Types/StringSubstitution.swift | 14 +- Sources/StringCatalog/Types/StringUnit.swift | 4 +- .../StringCatalog/Types/StringUnitState.swift | 2 +- .../StringCatalog/Types/StringVariation.swift | 6 +- .../Types/StringVariations.swift | 18 +- .../Utilities/DictionaryWrapper.swift | 4 + .../StringCatalogTranslator.swift | 238 --------- .../TranslateStringCatalog.swift | 75 --- Sources/StringCatalogTranslatorRun/main.swift | 15 - Sources/_Playground/main.swift | 78 --- .../CatalogTranslationEngineTests.swift | 192 ++++++++ .../TestResources/AppShortcuts.xcstrings | 0 .../TestResources/DipDictSettings.xcstrings | 0 .../StringSetUntranslated.xcstrings | 0 .../TestResources/_DipDictSettings.json | 0 .../StringCatalogCodableTests.swift | 51 ++ .../TestResources/AppShortcuts.xcstrings | 112 +++++ .../TestResources/DipDictSettings.xcstrings | 166 +++++++ .../StringSetUntranslated.xcstrings | 35 ++ .../TestResources/_DipDictSettings.json | 461 ++++++++++++++++++ .../StringCatalogCodableTests.swift | 71 --- .../StringCatalogTranslatorTests.swift | 128 ----- 34 files changed, 2034 insertions(+), 791 deletions(-) delete mode 100644 Package.resolved create mode 100644 README.md create mode 100644 Sources/CatalogTranslation/CatalogFileTranslationService.swift create mode 100644 Sources/CatalogTranslation/CatalogTranslationEngine.swift create mode 100644 Sources/CatalogTranslation/Types.swift create mode 100644 Sources/CatalogTranslationLLMExample/LLMExampleTranslator.swift delete mode 100644 Sources/StringCatalogTranslator/StringCatalogTranslator.swift delete mode 100644 Sources/StringCatalogTranslator/TranslateStringCatalog.swift delete mode 100644 Sources/StringCatalogTranslatorRun/main.swift delete mode 100644 Sources/_Playground/main.swift create mode 100644 Tests/CatalogTranslationTests/CatalogTranslationEngineTests.swift rename Tests/{StringCatalogTranslatorTests => CatalogTranslationTests}/TestResources/AppShortcuts.xcstrings (100%) rename Tests/{StringCatalogTranslatorTests => CatalogTranslationTests}/TestResources/DipDictSettings.xcstrings (100%) rename Tests/{StringCatalogTranslatorTests => CatalogTranslationTests}/TestResources/StringSetUntranslated.xcstrings (100%) rename Tests/{StringCatalogTranslatorTests => CatalogTranslationTests}/TestResources/_DipDictSettings.json (100%) create mode 100644 Tests/StringCatalogTests/StringCatalogCodableTests.swift create mode 100644 Tests/StringCatalogTests/TestResources/AppShortcuts.xcstrings create mode 100644 Tests/StringCatalogTests/TestResources/DipDictSettings.xcstrings create mode 100644 Tests/StringCatalogTests/TestResources/StringSetUntranslated.xcstrings create mode 100644 Tests/StringCatalogTests/TestResources/_DipDictSettings.json delete mode 100644 Tests/StringCatalogTranslatorTests/StringCatalogCodableTests.swift delete mode 100644 Tests/StringCatalogTranslatorTests/StringCatalogTranslatorTests.swift diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index 5b0e1e1..0000000 --- a/Package.resolved +++ /dev/null @@ -1,113 +0,0 @@ -{ - "pins" : [ - { - "identity" : "combine-schedulers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/combine-schedulers", - "state" : { - "revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6", - "version" : "1.1.0" - } - }, - { - "identity" : "openai", - "kind" : "remoteSourceControl", - "location" : "https://github.com/MacPaw/OpenAI", - "state" : { - "revision" : "28884fcfb15946b119b6eb3498e5a900c1d8d595", - "version" : "0.4.7" - } - }, - { - "identity" : "swift-clocks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-clocks", - "state" : { - "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", - "version" : "1.0.6" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" - } - }, - { - "identity" : "swift-custom-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", - "state" : { - "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", - "version" : "1.3.3" - } - }, - { - "identity" : "swift-dependencies", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-dependencies", - "state" : { - "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", - "version" : "1.10.0" - } - }, - { - "identity" : "swift-http-types", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-types", - "state" : { - "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", - "version" : "1.5.1" - } - }, - { - "identity" : "swift-openapi-runtime", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-openapi-runtime", - "state" : { - "revision" : "7cdf33371bf89b23b9cf4fd3ce8d3c825c28fbe8", - "version" : "1.9.0" - } - }, - { - "identity" : "swift-openapi-urlsession", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-openapi-urlsession", - "state" : { - "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", - "state" : { - "revision" : "4799286537280063c85a32f09884cfbca301b1a1", - "version" : "602.0.0" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "31073495cae9caf243c440eac94b3ab067e3d7bc", - "version" : "1.8.0" - } - } - ], - "version" : 2 -} diff --git a/Package.swift b/Package.swift index 6b4d857..d954f89 100644 --- a/Package.swift +++ b/Package.swift @@ -5,60 +5,51 @@ import PackageDescription let package = Package( name: "StringCatalogKit", - platforms: [.macOS(.v14)], + platforms: [ + .iOS(.v15), + .macOS(.v12), + .tvOS(.v15), + .watchOS(.v8), + .visionOS(.v1), + ], products: [ .library(name: "StringCatalog", targets: ["StringCatalog"]), - .library(name: "StringCatalogTranslator", targets: ["StringCatalogTranslator"]), - ], - dependencies: [ - .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"), - .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.2.1"), - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.0"), - // - .package(path: "../SwiftDeepL"), - .package(path: "../OpenAIDependency"), + .library(name: "CatalogTranslation", targets: ["CatalogTranslation"]), + .library(name: "CatalogTranslationLLMExample", targets: ["CatalogTranslationLLMExample"]), ], + dependencies: [], targets: [ .target(name: "StringCatalog"), .target( - name: "StringCatalogTranslator", + name: "CatalogTranslation", dependencies: [ .StringCatalog, - .ConcurrencyExtras, - .Dependencies, - .DependenciesMacros, - .DeepLURLSessionDependency, - .OpenAIDependency, + ] + ), + .target( + name: "CatalogTranslationLLMExample", + dependencies: [ + .CatalogTranslation, ] ), .testTarget( - name: "StringCatalogTranslatorTests", - dependencies: [.StringCatalogTranslator, .CustomDump], + name: "StringCatalogTests", + dependencies: [.StringCatalog], resources: [ .copy("TestResources") ] ), - .executableTarget(name: "StringCatalogTranslatorRun", - dependencies: [.StringCatalogTranslator] - ), - // - .executableTarget( - name: "_Playground", - dependencies: [ - .StringCatalog, - .StringCatalogTranslator, + .testTarget( + name: "CatalogTranslationTests", + dependencies: [.CatalogTranslation, .StringCatalog], + resources: [ + .copy("TestResources") ] ), ] ) extension Target.Dependency { - static let ConcurrencyExtras: Self = .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras") static let StringCatalog: Self = "StringCatalog" - static let StringCatalogTranslator: Self = "StringCatalogTranslator" - static let Dependencies = Self.product(name: "Dependencies", package: "swift-dependencies") - static let DependenciesMacros = Self.product(name: "DependenciesMacros", package: "swift-dependencies") - static let DeepLURLSessionDependency = Self.product(name: "DeepLURLSessionDependency", package: "SwiftDeepL") - static let CustomDump = Self.product(name: "CustomDump", package: "swift-custom-dump") - static let OpenAIDependency = Self.product(name: "OpenAIDependency", package: "OpenAIDependency") + static let CatalogTranslation: Self = "CatalogTranslation" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..348dc41 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# StringCatalogKit + +`StringCatalogKit` provides model types for `.xcstrings` files and a provider-agnostic translation engine. + +## Products + +- `StringCatalog`: Codable model types for Xcode string catalogs. +- `CatalogTranslation`: Translation engine + file planning/apply API. +- `CatalogTranslationLLMExample`: Optional example adapter for LLM prompt templating. + +## Quick Start + +```swift +import CatalogTranslation +import StringCatalog + +struct MyTranslator: CatalogTextTranslator { + func translate(_ request: TranslationRequest) async throws -> String { + // Your API call here (OpenAI, DeepL, custom service...) + return "translated: \(request.text)" + } +} + +let translator = MyTranslator() +let engine = CatalogTranslationEngine(translator: translator) + +let catalog = try StringCatalog(contentsOf: URL(fileURLWithPath: "/path/Localizable.xcstrings")) +let result = try await engine.translateCatalog(catalog, to: .german) + +print(result.report.stats) +``` + +## File Workflow + +```swift +let service = CatalogFileTranslationService(engine: engine) +let plan = try await service.plan( + in: [URL(fileURLWithPath: "/path/to/project")], + targetLanguages: [.german, .french] +) + +// Dry run first: +print(plan.items.filter(\.changed).count) + +// Explicitly apply: +let applyReport = try service.apply(plan) +print(applyReport.writtenFiles) +``` + +## Migration (Old -> New) + +- Removed `swift-dependencies` usage from translation APIs. +- Removed built-in DeepL/OpenAI hard-coded paths from core. +- Replaced static `TranslateStringCatalog` entry points with instance APIs: + - `CatalogTranslationEngine` + - `CatalogFileTranslationService` +- Replaced closed `StringLanguage` enum in core storage with flexible `LanguageCode`. diff --git a/Sources/CatalogTranslation/CatalogFileTranslationService.swift b/Sources/CatalogTranslation/CatalogFileTranslationService.swift new file mode 100644 index 0000000..a3a60c4 --- /dev/null +++ b/Sources/CatalogTranslation/CatalogFileTranslationService.swift @@ -0,0 +1,117 @@ +import Foundation +import StringCatalog + +public struct CatalogFileTranslationService: Sendable { + private let engine: CatalogTranslationEngine + + public init(engine: CatalogTranslationEngine) { + self.engine = engine + } + + public func plan( + in directories: [URL], + targetLanguages: [LanguageCode], + mode: TranslationMode = .bestEffort + ) async throws -> FileTranslationPlan { + let files = try Self.stringCatalogFiles(in: directories) + + var items = [FileTranslationPlan.Item]() + var changes = [PlannedFileChange]() + + for fileURL in files { + let originalContent = try String(contentsOf: fileURL) + var catalog = try StringCatalog(contentsOf: fileURL) + + var aggregateStats = TranslationStats() + var aggregateFailures = [TranslationFailure]() + + for targetLanguage in targetLanguages { + let result = try await engine.translateCatalog(catalog, to: targetLanguage, mode: mode) + catalog = result.catalog + + aggregateStats.attemptedSegments += result.report.stats.attemptedSegments + aggregateStats.translatedSegments += result.report.stats.translatedSegments + aggregateStats.failedSegments += result.report.stats.failedSegments + aggregateStats.skippedSegments += result.report.stats.skippedSegments + aggregateFailures.append(contentsOf: result.report.failures) + } + + let updatedContent = try catalog.encodePrettyToString() + let changed = updatedContent != originalContent + + if changed { + changes.append( + PlannedFileChange( + fileURL: fileURL, + originalContent: originalContent, + updatedContent: updatedContent + ) + ) + } + + items.append( + .init( + fileURL: fileURL, + changed: changed, + stats: aggregateStats, + failures: aggregateFailures + ) + ) + } + + return FileTranslationPlan(items: items, changes: changes) + } + + public func apply(_ plan: FileTranslationPlan) throws -> FileApplyReport { + var writtenFiles = [URL]() + + for change in plan.changes { + try change.updatedContent.write(to: change.fileURL, atomically: true, encoding: .utf8) + writtenFiles.append(change.fileURL) + } + + return FileApplyReport(writtenFiles: writtenFiles) + } + + public static func stringCatalogFiles(in directories: [URL]) throws -> [URL] { + var seen = Set() + var files = [URL]() + + for directory in directories { + let discovered = try stringCatalogFiles(in: directory) + for file in discovered { + let path = file.path + if seen.insert(path).inserted { + files.append(file) + } + } + } + + return files.sorted(by: { $0.path < $1.path }) + } + + public static func stringCatalogFiles(in directory: URL) throws -> [URL] { + var files = [URL]() + + if let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) { + for case let fileURL as URL in enumerator { + do { + let fileAttributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) + if let isRegularFile = fileAttributes.isRegularFile, + isRegularFile, + fileURL.lastPathComponent.hasSuffix("xcstrings") { + files.append(fileURL) + } + } catch { + continue + } + } + } + + return files.sorted(by: { $0.path < $1.path }) + } +} diff --git a/Sources/CatalogTranslation/CatalogTranslationEngine.swift b/Sources/CatalogTranslation/CatalogTranslationEngine.swift new file mode 100644 index 0000000..765ee0d --- /dev/null +++ b/Sources/CatalogTranslation/CatalogTranslationEngine.swift @@ -0,0 +1,436 @@ +import Foundation +import StringCatalog + +public struct CatalogTranslationEngine: Sendable { + private let options: TranslationOptions + private let translateClosure: @Sendable (TranslationRequest) async throws -> String + + public init(translator: any CatalogTextTranslator, options: TranslationOptions = .default) { + self.options = options + self.translateClosure = { request in + try await translator.translate(request) + } + } + + public func translateCatalog( + _ catalog: StringCatalog, + to targetLanguage: LanguageCode, + mode: TranslationMode = .bestEffort + ) async throws -> CatalogTranslationResult { + let jobs = buildJobs(for: catalog, targetLanguage: targetLanguage) + var stats = TranslationStats(attemptedSegments: jobs.count) + var failures = [TranslationFailure]() + var successfulTranslations = [(job: Job, translatedText: String)]() + + let semaphore = AsyncSemaphore(value: options.maxConcurrentRequests) + + do { + try await withThrowingTaskGroup(of: JobOutcome.self) { group in + for job in jobs { + group.addTask { + await semaphore.wait() + + do { + let translatedText = try await translateClosure(job.request) + await semaphore.signal() + return .success(job: job, translatedText: translatedText) + } catch { + await semaphore.signal() + let failure = TranslationFailure( + stringKey: job.request.stringKey, + segment: job.request.segment, + sourceLanguage: job.request.sourceLanguage, + targetLanguage: job.request.targetLanguage, + reason: String(describing: error) + ) + + if mode == .strict { + throw StrictModeError.failure(failure) + } + + return .failure(failure) + } + } + } + + while let outcome = try await group.next() { + switch outcome { + case .success(let job, let translatedText): + successfulTranslations.append((job: job, translatedText: translatedText)) + case .failure(let failure): + failures.append(failure) + } + } + } + } catch let strictError as StrictModeError { + switch strictError { + case .failure(let failure): + throw CatalogTranslationEngineError.strictFailure(failure) + } + } + + var translatedCatalog = catalog + + for translation in successfulTranslations { + let sourceLocalization = catalog.strings[translation.job.request.stringKey]?.localizations?[catalog.sourceLanguage] + + apply( + translatedText: translation.translatedText, + segment: translation.job.internalSegment, + stringKey: translation.job.request.stringKey, + sourceLocalization: sourceLocalization, + targetLanguage: targetLanguage, + to: &translatedCatalog + ) + stats.translatedSegments += 1 + } + + stats.failedSegments = failures.count + stats.skippedSegments = max(0, stats.attemptedSegments - stats.translatedSegments - stats.failedSegments) + + return CatalogTranslationResult( + catalog: translatedCatalog, + report: TranslationReport(stats: stats, failures: failures) + ) + } +} + +private extension CatalogTranslationEngine { + struct Job: Sendable { + let request: TranslationRequest + let internalSegment: InternalSegment + } + + enum InternalSegment: Sendable { + case stringUnit + case stringSet(index: Int, sourceCount: Int) + case variation(InternalVariationPath) + } + + enum InternalVariationPath: Sendable { + case localizationDevice(StringVariations.DeviceKey) + case localizationPlural(StringVariations.PluralKey) + case substitutionDevice(substitutionKey: String, deviceKey: StringVariations.DeviceKey) + case substitutionPlural(substitutionKey: String, pluralKey: StringVariations.PluralKey) + + var publicPath: String { + switch self { + case .localizationDevice(let deviceKey): + return "variations.device.\(deviceKey.rawValue)" + case .localizationPlural(let pluralKey): + return "variations.plural.\(pluralKey.rawValue)" + case .substitutionDevice(let substitutionKey, let deviceKey): + return "substitutions.\(substitutionKey).variations.device.\(deviceKey.rawValue)" + case .substitutionPlural(let substitutionKey, let pluralKey): + return "substitutions.\(substitutionKey).variations.plural.\(pluralKey.rawValue)" + } + } + } + + enum JobOutcome: Sendable { + case success(job: Job, translatedText: String) + case failure(TranslationFailure) + } + + enum StrictModeError: Error { + case failure(TranslationFailure) + } + + func buildJobs(for catalog: StringCatalog, targetLanguage: LanguageCode) -> [Job] { + var jobs = [Job]() + let sourceLanguage = catalog.sourceLanguage + + for (stringKey, stringEntry) in catalog.strings { + let sourceLocalization = stringEntry.localizations?[sourceLanguage] + let targetLocalization = stringEntry.localizations?[targetLanguage] + + if targetLocalization?.stringUnit == nil { + let text = sourceLocalization?.stringUnit?.value ?? stringKey + if !text.isEmpty { + let request = TranslationRequest( + text: text, + sourceLanguage: sourceLanguage, + targetLanguage: targetLanguage, + stringKey: stringKey, + developerComment: stringEntry.comment, + segment: .stringUnit + ) + + jobs.append(Job(request: request, internalSegment: .stringUnit)) + } + } + + if let sourceStringSet = sourceLocalization?.stringSet { + for (index, value) in sourceStringSet.values.enumerated() { + if value.isEmpty { + continue + } + + if let targetValues = targetLocalization?.stringSet?.values, + targetValues.indices.contains(index), + !targetValues[index].isEmpty { + continue + } + + let request = TranslationRequest( + text: value, + sourceLanguage: sourceLanguage, + targetLanguage: targetLanguage, + stringKey: stringKey, + developerComment: stringEntry.comment, + segment: .stringSet(index: index) + ) + + jobs.append( + Job( + request: request, + internalSegment: .stringSet(index: index, sourceCount: sourceStringSet.values.count) + ) + ) + } + } + + if let sourceVariations = sourceLocalization?.variations { + appendVariationJobs( + sourceVariations: sourceVariations, + targetVariations: targetLocalization?.variations, + stringKey: stringKey, + comment: stringEntry.comment, + sourceLanguage: sourceLanguage, + targetLanguage: targetLanguage, + destination: &jobs + ) + } + + if let sourceSubstitutions = sourceLocalization?.substitutions { + for (substitutionKey, sourceSubstitution) in sourceSubstitutions { + guard let sourceVariations = sourceSubstitution.variations else { + continue + } + + let targetVariations = targetLocalization?.substitutions?[substitutionKey]?.variations + + appendVariationJobs( + sourceVariations: sourceVariations, + targetVariations: targetVariations, + stringKey: stringKey, + comment: stringEntry.comment, + sourceLanguage: sourceLanguage, + targetLanguage: targetLanguage, + substitutionKey: substitutionKey, + destination: &jobs + ) + } + } + } + + return jobs + } + + func appendVariationJobs( + sourceVariations: StringVariations, + targetVariations: StringVariations?, + stringKey: String, + comment: String?, + sourceLanguage: LanguageCode, + targetLanguage: LanguageCode, + substitutionKey: String? = nil, + destination: inout [Job] + ) { + for (deviceKey, variation) in sourceVariations.device ?? [:] { + let text = variation.stringUnit.value + if text.isEmpty { + continue + } + + let targetValue = targetVariations?.device?[deviceKey]?.stringUnit.value + if let targetValue, !targetValue.isEmpty { + continue + } + + let internalPath: InternalVariationPath = if let substitutionKey { + .substitutionDevice(substitutionKey: substitutionKey, deviceKey: deviceKey) + } else { + .localizationDevice(deviceKey) + } + + destination.append( + Job( + request: TranslationRequest( + text: text, + sourceLanguage: sourceLanguage, + targetLanguage: targetLanguage, + stringKey: stringKey, + developerComment: comment, + segment: .variation(path: internalPath.publicPath) + ), + internalSegment: .variation(internalPath) + ) + ) + } + + for (pluralKey, variation) in sourceVariations.plural ?? [:] { + let text = variation.stringUnit.value + if text.isEmpty { + continue + } + + let targetValue = targetVariations?.plural?[pluralKey]?.stringUnit.value + if let targetValue, !targetValue.isEmpty { + continue + } + + let internalPath: InternalVariationPath = if let substitutionKey { + .substitutionPlural(substitutionKey: substitutionKey, pluralKey: pluralKey) + } else { + .localizationPlural(pluralKey) + } + + destination.append( + Job( + request: TranslationRequest( + text: text, + sourceLanguage: sourceLanguage, + targetLanguage: targetLanguage, + stringKey: stringKey, + developerComment: comment, + segment: .variation(path: internalPath.publicPath) + ), + internalSegment: .variation(internalPath) + ) + ) + } + } + + func apply( + translatedText: String, + segment: InternalSegment, + stringKey: String, + sourceLocalization: StringLocalization?, + targetLanguage: LanguageCode, + to catalog: inout StringCatalog + ) { + guard var entry = catalog.strings[stringKey] else { + return + } + + var localizations = entry.localizations ?? [:] + var targetLocalization = localizations[targetLanguage] ?? StringLocalization() + + switch segment { + case .stringUnit: + targetLocalization.stringUnit = StringUnit(state: .translated, value: translatedText) + + case .stringSet(let index, let sourceCount): + var stringSet = targetLocalization.stringSet + if stringSet == nil { + var values = Array(repeating: "", count: sourceCount) + if let sourceValues = sourceLocalization?.stringSet?.values { + for i in 0..] = [] + + init(value: Int) { + self.value = max(1, value) + } + + func wait() async { + if value > 0 { + value -= 1 + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func signal() { + if let waiter = waiters.first { + waiters.removeFirst() + waiter.resume() + } else { + value += 1 + } + } +} diff --git a/Sources/CatalogTranslation/Types.swift b/Sources/CatalogTranslation/Types.swift new file mode 100644 index 0000000..cff1caf --- /dev/null +++ b/Sources/CatalogTranslation/Types.swift @@ -0,0 +1,161 @@ +import Foundation +import StringCatalog + +public protocol CatalogTextTranslator: Sendable { + func translate(_ request: TranslationRequest) async throws -> String +} + +public struct TranslationRequest: Sendable, Hashable { + public let text: String + public let sourceLanguage: LanguageCode + public let targetLanguage: LanguageCode + public let stringKey: String + public let developerComment: String? + public let segment: TranslationSegment + + public init( + text: String, + sourceLanguage: LanguageCode, + targetLanguage: LanguageCode, + stringKey: String, + developerComment: String?, + segment: TranslationSegment + ) { + self.text = text + self.sourceLanguage = sourceLanguage + self.targetLanguage = targetLanguage + self.stringKey = stringKey + self.developerComment = developerComment + self.segment = segment + } +} + +public enum TranslationSegment: Sendable, Hashable { + case stringUnit + case stringSet(index: Int) + case variation(path: String) +} + +public enum TranslationMode: Sendable { + case bestEffort + case strict +} + +public struct TranslationOptions: Sendable { + public var maxConcurrentRequests: Int + + public init(maxConcurrentRequests: Int = 6) { + self.maxConcurrentRequests = max(1, maxConcurrentRequests) + } + + public static let `default` = Self() +} + +public struct TranslationFailure: Sendable, Hashable { + public let stringKey: String + public let segment: TranslationSegment + public let sourceLanguage: LanguageCode + public let targetLanguage: LanguageCode + public let reason: String + + public init( + stringKey: String, + segment: TranslationSegment, + sourceLanguage: LanguageCode, + targetLanguage: LanguageCode, + reason: String + ) { + self.stringKey = stringKey + self.segment = segment + self.sourceLanguage = sourceLanguage + self.targetLanguage = targetLanguage + self.reason = reason + } +} + +public struct TranslationStats: Sendable, Hashable { + public var attemptedSegments: Int + public var translatedSegments: Int + public var failedSegments: Int + public var skippedSegments: Int + + public init( + attemptedSegments: Int = 0, + translatedSegments: Int = 0, + failedSegments: Int = 0, + skippedSegments: Int = 0 + ) { + self.attemptedSegments = attemptedSegments + self.translatedSegments = translatedSegments + self.failedSegments = failedSegments + self.skippedSegments = skippedSegments + } +} + +public struct TranslationReport: Sendable, Hashable { + public let stats: TranslationStats + public let failures: [TranslationFailure] + + public init(stats: TranslationStats, failures: [TranslationFailure]) { + self.stats = stats + self.failures = failures + } +} + +public struct CatalogTranslationResult: Sendable { + public let catalog: StringCatalog + public let report: TranslationReport + + public init(catalog: StringCatalog, report: TranslationReport) { + self.catalog = catalog + self.report = report + } +} + +public enum CatalogTranslationEngineError: Error, Sendable { + case strictFailure(TranslationFailure) +} + +public struct PlannedFileChange: Sendable { + public let fileURL: URL + public let originalContent: String + public let updatedContent: String + + public init(fileURL: URL, originalContent: String, updatedContent: String) { + self.fileURL = fileURL + self.originalContent = originalContent + self.updatedContent = updatedContent + } +} + +public struct FileTranslationPlan: Sendable { + public struct Item: Sendable { + public let fileURL: URL + public let changed: Bool + public let stats: TranslationStats + public let failures: [TranslationFailure] + + public init(fileURL: URL, changed: Bool, stats: TranslationStats, failures: [TranslationFailure]) { + self.fileURL = fileURL + self.changed = changed + self.stats = stats + self.failures = failures + } + } + + public let items: [Item] + public let changes: [PlannedFileChange] + + public init(items: [Item], changes: [PlannedFileChange]) { + self.items = items + self.changes = changes + } +} + +public struct FileApplyReport: Sendable { + public let writtenFiles: [URL] + + public init(writtenFiles: [URL]) { + self.writtenFiles = writtenFiles + } +} diff --git a/Sources/CatalogTranslationLLMExample/LLMExampleTranslator.swift b/Sources/CatalogTranslationLLMExample/LLMExampleTranslator.swift new file mode 100644 index 0000000..dd03bd6 --- /dev/null +++ b/Sources/CatalogTranslationLLMExample/LLMExampleTranslator.swift @@ -0,0 +1,69 @@ +import CatalogTranslation +import Foundation +import StringCatalog + +public struct PromptTemplate: Sendable { + public var makeSystemPrompt: @Sendable (TranslationRequest) -> String + public var makeUserPrompt: @Sendable (TranslationRequest) -> String + + public init( + makeSystemPrompt: @escaping @Sendable (TranslationRequest) -> String, + makeUserPrompt: @escaping @Sendable (TranslationRequest) -> String + ) { + self.makeSystemPrompt = makeSystemPrompt + self.makeUserPrompt = makeUserPrompt + } + + public static let appLocalizationDefault = Self( + makeSystemPrompt: { request in + let source = request.sourceLanguage.englishDisplayName + let target = request.targetLanguage.englishDisplayName + return """ + You are an expert app localization translator. + Translate from \(source) to \(target). + Preserve placeholders and markdown formatting. + Return only the translated text. + """ + }, + makeUserPrompt: { request in + let context = request.developerComment.map { "Context: \($0)\n" } ?? "" + return """ + \(context)String key: \(request.stringKey) + Segment: \(segmentDescription(request.segment)) + + Text: + \(request.text) + """ + } + ) +} + +public struct LLMExampleTranslator: CatalogTextTranslator, Sendable { + private let template: PromptTemplate + private let complete: @Sendable (_ request: TranslationRequest, _ systemPrompt: String, _ userPrompt: String) async throws -> String + + public init( + template: PromptTemplate = .appLocalizationDefault, + complete: @escaping @Sendable (_ request: TranslationRequest, _ systemPrompt: String, _ userPrompt: String) async throws -> String + ) { + self.template = template + self.complete = complete + } + + public func translate(_ request: TranslationRequest) async throws -> String { + let systemPrompt = template.makeSystemPrompt(request) + let userPrompt = template.makeUserPrompt(request) + return try await complete(request, systemPrompt, userPrompt) + } +} + +private func segmentDescription(_ segment: TranslationSegment) -> String { + switch segment { + case .stringUnit: + return "stringUnit" + case .stringSet(let index): + return "stringSet[\(index)]" + case .variation(let path): + return path + } +} diff --git a/Sources/StringCatalog/StringCatalog.swift b/Sources/StringCatalog/StringCatalog.swift index ee33bae..83bf8e3 100644 --- a/Sources/StringCatalog/StringCatalog.swift +++ b/Sources/StringCatalog/StringCatalog.swift @@ -1,9 +1,15 @@ import Foundation -public struct StringCatalog: Codable, Equatable { - public var sourceLanguage: StringLanguage +public struct StringCatalog: Codable, Equatable, Sendable { + public var sourceLanguage: LanguageCode public var strings: [String: StringEntry] public var version: String // TODO: Use a Version type? + + public init(sourceLanguage: LanguageCode, strings: [String: StringEntry], version: String) { + self.sourceLanguage = sourceLanguage + self.strings = strings + self.version = version + } } // MARK: - Init diff --git a/Sources/StringCatalog/Types/StringEntry.swift b/Sources/StringCatalog/Types/StringEntry.swift index 078b9ca..70df929 100644 --- a/Sources/StringCatalog/Types/StringEntry.swift +++ b/Sources/StringCatalog/Types/StringEntry.swift @@ -1,10 +1,50 @@ import Foundation -public struct StringEntry: Codable, Equatable { - // public typealias Localizations = DictionaryWrapper - +public struct StringEntry: Codable, Equatable, Sendable { public var comment: String? public var extractionState: StringExtractionState? - // public var localizations: Localizations? - public var localizations: [StringLanguage: StringLocalization]? + public var localizations: [LanguageCode: StringLocalization]? + + public init( + comment: String? = nil, + extractionState: StringExtractionState? = nil, + localizations: [LanguageCode: StringLocalization]? = nil + ) { + self.comment = comment + self.extractionState = extractionState + self.localizations = localizations + } + + enum CodingKeys: String, CodingKey { + case comment + case extractionState + case localizations + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.comment = try container.decodeIfPresent(String.self, forKey: .comment) + self.extractionState = try container.decodeIfPresent(StringExtractionState.self, forKey: .extractionState) + + if let rawLocalizations = try container.decodeIfPresent([String: StringLocalization].self, forKey: .localizations) { + self.localizations = Dictionary(uniqueKeysWithValues: rawLocalizations.map { key, value in + (LanguageCode(rawValue: key), value) + }) + } else { + self.localizations = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(comment, forKey: .comment) + try container.encodeIfPresent(extractionState, forKey: .extractionState) + + if let localizations { + let rawLocalizations = Dictionary(uniqueKeysWithValues: localizations.map { key, value in + (key.rawValue, value) + }) + try container.encode(rawLocalizations, forKey: .localizations) + } + } } diff --git a/Sources/StringCatalog/Types/StringExtractionState.swift b/Sources/StringCatalog/Types/StringExtractionState.swift index 11445fe..902bf1b 100644 --- a/Sources/StringCatalog/Types/StringExtractionState.swift +++ b/Sources/StringCatalog/Types/StringExtractionState.swift @@ -1,6 +1,6 @@ import Foundation -public struct StringExtractionState: Codable, Hashable, RawRepresentable, ExpressibleByStringLiteral, Equatable { +public struct StringExtractionState: Codable, Hashable, RawRepresentable, ExpressibleByStringLiteral, Equatable, Sendable { public let rawValue: String public init(rawValue: String) { diff --git a/Sources/StringCatalog/Types/StringLanguage.swift b/Sources/StringCatalog/Types/StringLanguage.swift index 6cb01ca..3c7a746 100644 --- a/Sources/StringCatalog/Types/StringLanguage.swift +++ b/Sources/StringCatalog/Types/StringLanguage.swift @@ -1,20 +1,34 @@ import Foundation -//public struct StringLanguage: Codable, Hashable, RawRepresentable, ExpressibleByStringLiteral { -// public let rawValue: String -// -// public init(rawValue: String) { -// self.rawValue = rawValue -// } -// -// public init(stringLiteral value: StringLiteralType) { -// self.init(rawValue: value) -// } -// -// public static let english = Self(rawValue: "en") -//} - -public enum StringLanguage: String, Codable, CodingKey, CodingKeyRepresentable, Equatable { +public struct LanguageCode: Codable, Hashable, RawRepresentable, ExpressibleByStringLiteral, CodingKey, Sendable { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public init?(stringValue: String) { + self.rawValue = stringValue + } + + public var stringValue: String { + rawValue + } + + public init?(intValue: Int) { + return nil + } + + public var intValue: Int? { + nil + } +} + +public enum KnownLanguage: String, Codable, CaseIterable, Sendable { case english = "en" case german = "de" case french = "fr" @@ -28,10 +42,13 @@ public enum StringLanguage: String, Codable, CodingKey, CodingKeyRepresentable, case portuguesePortugal = "pt-PT" case portugueseBrazil = "pt-BR" case russian = "ru" - // Add any other supported languages here } -extension StringLanguage { +extension KnownLanguage { + public var code: LanguageCode { + LanguageCode(rawValue: rawValue) + } + public var englishDisplayName: String { switch self { case .english: return "English" @@ -50,3 +67,30 @@ extension StringLanguage { } } } + +public extension LanguageCode { + static let english = KnownLanguage.english.code + static let german = KnownLanguage.german.code + static let french = KnownLanguage.french.code + static let turkish = KnownLanguage.turkish.code + static let polish = KnownLanguage.polish.code + static let spanish = KnownLanguage.spanish.code + static let chineseSimplified = KnownLanguage.chineseSimplified.code + static let japanese = KnownLanguage.japanese.code + static let italian = KnownLanguage.italian.code + static let korean = KnownLanguage.korean.code + static let portuguesePortugal = KnownLanguage.portuguesePortugal.code + static let portugueseBrazil = KnownLanguage.portugueseBrazil.code + static let russian = KnownLanguage.russian.code + + var knownLanguage: KnownLanguage? { + KnownLanguage(rawValue: rawValue) + } + + var englishDisplayName: String { + knownLanguage?.englishDisplayName ?? rawValue + } +} + +@available(*, deprecated, renamed: "LanguageCode") +public typealias StringLanguage = LanguageCode diff --git a/Sources/StringCatalog/Types/StringLocalization.swift b/Sources/StringCatalog/Types/StringLocalization.swift index c854276..70f1a15 100644 --- a/Sources/StringCatalog/Types/StringLocalization.swift +++ b/Sources/StringCatalog/Types/StringLocalization.swift @@ -1,6 +1,6 @@ import Foundation -public struct StringLocalization: Codable, Equatable { +public struct StringLocalization: Codable, Equatable, Sendable { public var stringUnit: StringUnit? public var stringSet: StringSet? public var substitutions: [String: StringSubstitution]? @@ -13,6 +13,7 @@ public struct StringLocalization: Codable, Equatable { variations: StringVariations? = nil ) { self.stringUnit = stringUnit + self.stringSet = stringSet self.substitutions = substitutions self.variations = variations } diff --git a/Sources/StringCatalog/Types/StringSubstitution.swift b/Sources/StringCatalog/Types/StringSubstitution.swift index 13ad4f8..0d75f6c 100644 --- a/Sources/StringCatalog/Types/StringSubstitution.swift +++ b/Sources/StringCatalog/Types/StringSubstitution.swift @@ -1,7 +1,13 @@ import Foundation -public struct StringSubstitution: Codable, Equatable { - public let argNum: Int - public let formatSpecifier: String - public let variations: StringVariations? +public struct StringSubstitution: Codable, Equatable, Sendable { + public var argNum: Int + public var formatSpecifier: String + public var variations: StringVariations? + + public init(argNum: Int, formatSpecifier: String, variations: StringVariations? = nil) { + self.argNum = argNum + self.formatSpecifier = formatSpecifier + self.variations = variations + } } diff --git a/Sources/StringCatalog/Types/StringUnit.swift b/Sources/StringCatalog/Types/StringUnit.swift index 6b600b8..32a532a 100644 --- a/Sources/StringCatalog/Types/StringUnit.swift +++ b/Sources/StringCatalog/Types/StringUnit.swift @@ -6,7 +6,7 @@ import Foundation /// "value" : "Diktieren" /// } /// ``` -public struct StringUnit: Codable, Equatable { +public struct StringUnit: Codable, Equatable, Sendable { public var state: StringUnitState public var value: String @@ -26,7 +26,7 @@ public struct StringUnit: Codable, Equatable { /// ] /// } /// ``` -public struct StringSet: Codable, Equatable { +public struct StringSet: Codable, Equatable, Sendable { public var state: StringUnitState public var values: [String] diff --git a/Sources/StringCatalog/Types/StringUnitState.swift b/Sources/StringCatalog/Types/StringUnitState.swift index decbf8d..931b8e8 100644 --- a/Sources/StringCatalog/Types/StringUnitState.swift +++ b/Sources/StringCatalog/Types/StringUnitState.swift @@ -1,6 +1,6 @@ import Foundation -public struct StringUnitState: Codable, Hashable, RawRepresentable, ExpressibleByStringLiteral { +public struct StringUnitState: Codable, Hashable, RawRepresentable, ExpressibleByStringLiteral, Sendable { public let rawValue: String public init(rawValue: String) { diff --git a/Sources/StringCatalog/Types/StringVariation.swift b/Sources/StringCatalog/Types/StringVariation.swift index 2bd309b..abc903c 100644 --- a/Sources/StringCatalog/Types/StringVariation.swift +++ b/Sources/StringCatalog/Types/StringVariation.swift @@ -1,5 +1,9 @@ import Foundation -public struct StringVariation: Codable, Equatable { +public struct StringVariation: Codable, Equatable, Sendable { public var stringUnit: StringUnit + + public init(stringUnit: StringUnit) { + self.stringUnit = stringUnit + } } diff --git a/Sources/StringCatalog/Types/StringVariations.swift b/Sources/StringCatalog/Types/StringVariations.swift index 5330ffd..9400d7c 100644 --- a/Sources/StringCatalog/Types/StringVariations.swift +++ b/Sources/StringCatalog/Types/StringVariations.swift @@ -1,12 +1,20 @@ import Foundation -public struct StringVariations: Codable, Equatable { - public let device: DictionaryWrapper? - public let plural: DictionaryWrapper? +public struct StringVariations: Codable, Equatable, Sendable { + public var device: DictionaryWrapper? + public var plural: DictionaryWrapper? + + public init( + device: DictionaryWrapper? = nil, + plural: DictionaryWrapper? = nil + ) { + self.device = device + self.plural = plural + } } extension StringVariations { - public struct DeviceKey: Codable, Hashable, RawRepresentable, ExpressibleByStringLiteral { + public struct DeviceKey: Codable, Hashable, RawRepresentable, ExpressibleByStringLiteral, Sendable { public let rawValue: String public init(rawValue: String) { @@ -27,7 +35,7 @@ extension StringVariations { public static let other = Self(rawValue: "other") } - public struct PluralKey: Codable, Hashable, RawRepresentable, ExpressibleByStringLiteral { + public struct PluralKey: Codable, Hashable, RawRepresentable, ExpressibleByStringLiteral, Sendable { public let rawValue: String public init(rawValue: String) { diff --git a/Sources/StringCatalog/Utilities/DictionaryWrapper.swift b/Sources/StringCatalog/Utilities/DictionaryWrapper.swift index cf06371..afff480 100644 --- a/Sources/StringCatalog/Utilities/DictionaryWrapper.swift +++ b/Sources/StringCatalog/Utilities/DictionaryWrapper.swift @@ -70,6 +70,10 @@ extension DictionaryWrapper: Equatable where Self.WrappedValue: Equatable { extension DictionaryWrapper: Hashable where Self.WrappedValue: Hashable { } +// MARK: - Sendable +extension DictionaryWrapper: Sendable where Key: Sendable, Value: Sendable, Key.RawValue: Sendable { +} + // MARK: - Sequence extension DictionaryWrapper: Sequence { public typealias Element = (key: Key, value: Value) diff --git a/Sources/StringCatalogTranslator/StringCatalogTranslator.swift b/Sources/StringCatalogTranslator/StringCatalogTranslator.swift deleted file mode 100644 index bfbc247..0000000 --- a/Sources/StringCatalogTranslator/StringCatalogTranslator.swift +++ /dev/null @@ -1,238 +0,0 @@ -import OpenAI -import ConcurrencyExtras -import DeepLURLSessionClient -import DeepLURLSessionDependency -import Dependencies -import OpenAIDependency -import StringCatalog - -public struct StringCatalogTranslator { - public var translateWithDeepL: @Sendable (_ text: String, _ comment: String?, _ sourceLanguage: StringLanguage, _ targetLanguage: StringLanguage) async throws -> String - public var translateWithChatGPT: @Sendable (_ text: String, _ comment: String?, _ sourceLanguage: StringLanguage, _ targetLanguage: StringLanguage) async throws -> String - - public func translateCatalog(_ catalog: StringCatalog, to targetLanguage: StringLanguage, translationService: TranslationService) async throws -> StringCatalog { - var newCatalog = catalog - let sourceLanguage = catalog.sourceLanguage - - await withTaskGroup(of: (String, String, String?).self) { group in - - for (stringKey, stringEntry) in catalog.strings { - let textToTranslate: String - - if let localizations = stringEntry.localizations { // there is some localization - if let targetLocalization = localizations[targetLanguage] { // there is localization in the target language already. skip - continue - } else if let sourceLocalization = localizations[sourceLanguage] { // there is localization in the source language. use it - if let stringUnit = sourceLocalization.stringUnit { // stringUnit not stringSet - textToTranslate = stringUnit.value - } else { // stringSet - continue - } - } else { // there is localization in another language other than source or target - textToTranslate = stringKey - } - } else { - textToTranslate = stringKey - } - - guard !textToTranslate.isEmpty else { - continue - } - - group.addTask { - switch translationService { - case .deepL: - let translation = try? await self.translateWithDeepL(textToTranslate, stringEntry.comment, sourceLanguage, targetLanguage) - return (stringKey, textToTranslate, translation) - case .chatgpt: - let translation = try? await self.translateWithChatGPT(textToTranslate, stringEntry.comment, sourceLanguage, targetLanguage) - return (stringKey, textToTranslate, translation) - } - } - } - - for await (stringKey, original, translation) in group { - - if let translation { - if newCatalog.strings[stringKey]?.localizations == nil { - newCatalog.strings[stringKey]?.localizations = [StringLanguage: StringLocalization]() - } - - newCatalog.strings[stringKey]?.localizations?[targetLanguage] = StringLocalization(stringUnit: .init(state: .translated, value: translation)) - } - - // #if DEBUG - // print("📜", original, "🎢", translation!) - // print("🥁 value", newCatalog.strings[original]!.localizations![targetLanguage]!) - // #endif - } - } - - return newCatalog - } -} - -extension StringCatalogTranslator: DependencyKey { - public static let liveValue: Self = { - @Dependency(\.deepLURLSession) var deepLURLSession - @Dependency(\.openAI) var openAi - - return Self( - translateWithDeepL: { text, comment, sourceLanguage, targetLanguage in - let response = try await deepLURLSession.client().translateText( - .init( - body: .json( - .init( - text: [text], - source_lang: sourceLanguage.deepLSourceLanguage, - target_lang: targetLanguage.deepLTargetLanguage, - context: comment - ) - ) - ) - ) - guard - let translation = try response.ok.body.json.translations?.compactMap({ translationsPayload in - translationsPayload.text - }).joined() - else { - throw StringCatalogTranslator.Failure.translationsPayloadNil - } - return translation - }, - translateWithChatGPT: { text, comment, sourceLanguage, targetLanguage in - let additionalContext = if let comment { - " Additional context from the author: \(comment)." - } else { - "" - } - - let systemPrompt = """ - You’re a skilled translator with extensive experience in translating \(sourceLanguage.englishDisplayName) text to \(targetLanguage.englishDisplayName) while maintaining the original formatting, especially for markdown. Your expertise allows you to ensure that nuances in meaning and cultural context are carefully preserved in the translation, making it accessible and appropriate for \(targetLanguage.englishDisplayName) speakers. - - Your task is to translate a piece of UI of a macOS app written in \(sourceLanguage.englishDisplayName) to \(targetLanguage.englishDisplayName) language. The text was taken from Xcode string catalog. - If there is markdown formatting, ensure that the markdown formatting remains intact. - Keep the placeholder values such as ["@", "lld", ".2f", "1$@", "2$@", "3$@", "1$lld", "2$lld"] used in the string catalogs at the meaningfully correct place in the translation. - - Please keep in mind any specific context or tone that should be maintained during the translation, particularly regarding cultural references or idiomatic expressions. The author may add additional context on where the text is used in the UI. Use that information to choose the most suitable translation if there are multiple options. - - For further clarity, here’s how I would like the output formatted: - - For headings, translate the text while maintaining the heading level (e.g., # for H1, ## for H2). - - For lists, keep the bullet points or numbering intact while translating the content. - - For emphasized text (bold or italics), use the appropriate markdown syntax in \(targetLanguage.englishDisplayName). - - Keep the line breaks intact. - - Only output the translation without backticks. - """ - - let userPrompt = """ - The text I need you to translate is below delimited by triple backticks.\(additionalContext) - ``` - \(text) - ``` - """ - - let model = text.count > 500 ? "gpt-4.1" : "gpt-4.1-mini" - let client = openAi.client() - let query = ChatQuery(messages: [ - .system(.init(content: .textContent(systemPrompt))), - .user(.init(content: .string(userPrompt))) - ], model: model) - - let response = try await client.chats(query: query) - return response.choices.first?.message.content ?? "NO CHOICE" - } - ) - }() - - public static var previewValue: Self { - Self( - translateWithDeepL: { text, comment, sourceLanguage, targetLanguage in - "This is a DeepL translation from \(sourceLanguage.englishDisplayName) to \(targetLanguage.englishDisplayName). Original text: [\(text)]" - }, - translateWithChatGPT: { text, comment, sourceLanguage, targetLanguage in - "This is a ChatGPT translation from \(sourceLanguage.englishDisplayName) to \(targetLanguage.englishDisplayName). Original text: [\(text)]" - } - ) - } -} - -extension StringCatalogTranslator { - public enum TranslationService { - case deepL, chatgpt - } - - public enum Failure: Error { - case translationsPayloadNil - } -} - -extension DependencyValues { - public var stringCatalogTranslator: StringCatalogTranslator { - get { self[StringCatalogTranslator.self] } - set { self[StringCatalogTranslator.self] = newValue } - } -} - -extension StringLanguage { - var deepLTargetLanguage: DeepLURLSessionClient.Components.Schemas.TargetLanguageText { - switch self { - case .english: - return .EN_hyphen_US - case .german: - return .DE - case .french: - return .FR - case .turkish: - return .TR - case .polish: - return .PL - case .spanish: - return .ES - case .chineseSimplified: - return .ZH - case .japanese: - return .JA - case .italian: - return .IT - case .korean: - return .KO - case .portuguesePortugal: - return .PT_hyphen_PT - case .portugueseBrazil: - return .PT_hyphen_BR - case .russian: - return .RU - } - } - var deepLSourceLanguage: DeepLURLSessionClient.Components.Schemas.SourceLanguageText { - switch self { - case .english: - return .EN - case .german: - return .DE - case .french: - return .FR - case .turkish: - return .TR - case .polish: - return .PL - case .spanish: - return .ES - case .chineseSimplified: - return .ZH - case .japanese: - return .JA - case .italian: - return .IT - case .korean: - return .KO - case .portuguesePortugal: - return .PT - case .portugueseBrazil: - return .PT - case .russian: - return .RU - } - } -} diff --git a/Sources/StringCatalogTranslator/TranslateStringCatalog.swift b/Sources/StringCatalogTranslator/TranslateStringCatalog.swift deleted file mode 100644 index d5616b1..0000000 --- a/Sources/StringCatalogTranslator/TranslateStringCatalog.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Dependencies -import Foundation -import StringCatalog - -public enum TranslateStringCatalog { - static public func translateCatalogFiles(at directories: [URL], to targetLanguages: [StringLanguage], using translationService: StringCatalogTranslator.TranslationService) async throws { - for directory in directories { - try await translateCatalogFiles(at: directory, to: targetLanguages, using: translationService) - } - } - static public func translateCatalogFiles(at directory: URL, to targetLanguages: [StringLanguage], using translationService: StringCatalogTranslator.TranslationService) async throws { - for language in targetLanguages { - try await translateCatalogFiles(at: directory, to: language, using: translationService) - } - } - - static func translateCatalogFiles(at directory: URL, to targetLanguage: StringLanguage, using translationService: StringCatalogTranslator.TranslationService) async throws { - for file in try stringCatalogFiles(at: directory) { - try await translateCatalogFile(at: file, to: targetLanguage, using: translationService) - } - } - static func translateCatalogFile(at catalogFile: URL, to targetLanguage: StringLanguage, using translationService: StringCatalogTranslator.TranslationService) async throws { - @Dependency(\.stringCatalogTranslator) var stringCatalogTranslator - - let json = try String(contentsOf: catalogFile) - let jsonData = json.data(using: .utf8)! - let decoder = JSONDecoder() - let catalog = try decoder.decode(StringCatalog.self, from: jsonData) - - let translatedCatalog = try await stringCatalogTranslator.translateCatalog(catalog, to: targetLanguage, translationService: translationService) - let translatedCatalogJson = try translatedCatalog.encodePrettyToString() - if translatedCatalogJson != json { - try translatedCatalogJson.write(to: catalogFile, atomically: true, encoding: .utf8) - print("🌐", targetLanguage) - } - } - - public static func stringCatalogs(at directory: URL) throws -> [StringCatalog] { - var catalogs = [StringCatalog]() - for file in try stringCatalogFiles(at: directory) { - let json = try String(contentsOf: file) - let jsonData = json.data(using: .utf8)! - let decoder = JSONDecoder() - let catalog = try decoder.decode(StringCatalog.self, from: jsonData) - catalogs.append(catalog) - } - return catalogs - } - - /// ```swift - /// dump( - /// try stringCatalogFiles(at: URL(filePath: "/Users/atacan/Documents/myway/Repositories/dipdict_libraries/Sources")) - /// ) - /// ``` - public static func stringCatalogFiles(at directory: URL) throws -> [URL] { - var files = [URL]() - - if let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) { - for case let fileURL as URL in enumerator { - do { - let fileAttributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) - if let isRegularFile = fileAttributes.isRegularFile, - isRegularFile, - fileURL.lastPathComponent.hasSuffix("xcstrings") - { - files.append(fileURL) - } - } catch { print(error, fileURL) } - } - } - - return files - } - -} diff --git a/Sources/StringCatalogTranslatorRun/main.swift b/Sources/StringCatalogTranslatorRun/main.swift deleted file mode 100644 index f35f191..0000000 --- a/Sources/StringCatalogTranslatorRun/main.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// https://github.com/atacan -// 17.03.24 - -import Dependencies -import Foundation -import StringCatalog -import StringCatalogTranslator - -try await withDependencies { - // $0.stringCatalogTranslator = .previewValue - $0.stringCatalogTranslator = .liveValue -} operation: { - try await TranslateStringCatalog.translateCatalogFiles(at: URL(filePath: "/Users/atacan/Documents/myway/Repositories/dipdict_libraries/"), to: [.german, .french, .turkish], using: .chatgpt) -} diff --git a/Sources/_Playground/main.swift b/Sources/_Playground/main.swift deleted file mode 100644 index 92a02d5..0000000 --- a/Sources/_Playground/main.swift +++ /dev/null @@ -1,78 +0,0 @@ -import ConcurrencyExtras -import Foundation -import StringCatalog - -let jsonString = """ - { - "sourceLanguage" : "en", - "strings" : { - "Use this model to transcribe" : { - - }, - - "Your clipboard does not change" : { - - }, - "bla bla" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "bla bla" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bla bla" - } - } - } - }, - }, - "version" : "1.0" - } - """ - -//let jsonData = jsonString.data(using: .utf8)! -let json = try String(contentsOf: URL(filePath: "/Users/atacan/Documents/myway/Repositories/dipdict_libraries/Sources/Settings/Resources/Localizable.xcstrings")) -let jsonData = json.data(using: .utf8)! - -let decoder = JSONDecoder() -var catalog = try decoder.decode(StringCatalog.self, from: jsonData) -print("Source Language: \(catalog.sourceLanguage)") -//print("Version: \(catalog.version)") -// Now you can access `catalog.strings` dictionary and each string with its localizations. -// dump(catalog) - -func encodeCatalogToString(catalog: StringCatalog) throws -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - - let encodedData = try encoder.encode(catalog) - return String(data: encodedData, encoding: .utf8)! -} - -func translate(text: String, comment: String? = nil) async throws -> String { - return "Translating [\(text)] using comment: [\(comment ?? "NO COMMENT")]" -} - -func translateCatalog(catalog: StringCatalog) async throws -> StringCatalog { - let catalogIsolated = ActorIsolated(catalog) - await withThrowingTaskGroup(of: Void.self) { group in - for index in catalog.strings.indices { - group.addTask { - let key = catalog.strings.keys[index] - let value = catalog.strings.values[index] - let translation = try await translate(text: key, comment: value.comment) - await catalogIsolated.withValue { - $0.strings[key]?.localizations?[.german] = StringLocalization(stringUnit: .init(state: .translated, value: translation)) - } - } - } - } - return await catalogIsolated.value -} - -let encodedCatalog = try encodeCatalogToString(catalog: catalog) -print(encodedCatalog) diff --git a/Tests/CatalogTranslationTests/CatalogTranslationEngineTests.swift b/Tests/CatalogTranslationTests/CatalogTranslationEngineTests.swift new file mode 100644 index 0000000..5b13b58 --- /dev/null +++ b/Tests/CatalogTranslationTests/CatalogTranslationEngineTests.swift @@ -0,0 +1,192 @@ +import CatalogTranslation +import Foundation +import StringCatalog +import XCTest + +final class CatalogTranslationEngineTests: XCTestCase { + func testBestEffortCollectsFailures() async throws { + let translator = ClosureTranslator { _ in + struct DummyError: Error {} + throw DummyError() + } + + let engine = CatalogTranslationEngine(translator: translator) + let catalog = makeSimpleCatalog() + + let result = try await engine.translateCatalog(catalog, to: .german, mode: .bestEffort) + + XCTAssertEqual(result.report.stats.attemptedSegments, 1) + XCTAssertEqual(result.report.stats.translatedSegments, 0) + XCTAssertEqual(result.report.stats.failedSegments, 1) + XCTAssertEqual(result.report.failures.count, 1) + XCTAssertEqual(result.catalog, catalog) + } + + func testStrictThrowsOnFailure() async throws { + let translator = ClosureTranslator { _ in + struct DummyError: Error {} + throw DummyError() + } + + let engine = CatalogTranslationEngine(translator: translator) + let catalog = makeSimpleCatalog() + + do { + _ = try await engine.translateCatalog(catalog, to: .german, mode: .strict) + XCTFail("Expected strict mode to throw") + } catch let error as CatalogTranslationEngineError { + switch error { + case .strictFailure(let failure): + XCTAssertEqual(failure.stringKey, "Hello") + } + } + } + + func testTranslatesAllSegmentKindsAndPreservesExistingTargets() async throws { + let translator = ClosureTranslator { request in + "de::\(request.text)" + } + + let engine = CatalogTranslationEngine(translator: translator) + let catalog = makeComplexCatalog() + + let result = try await engine.translateCatalog(catalog, to: .german, mode: .bestEffort) + + let hello = result.catalog.strings["Hello"]?.localizations?[.german]?.stringUnit?.value + XCTAssertEqual(hello, "de::Hello") + + let listValues = result.catalog.strings["List"]?.localizations?[.german]?.stringSet?.values + XCTAssertEqual(listValues?[0], "de::One") + XCTAssertEqual(listValues?[1], "de::Two") + + let preserved = result.catalog.strings["Already Translated"]?.localizations?[.german]?.stringUnit?.value + XCTAssertEqual(preserved, "Bereits da") + + let deviceVariation = result.catalog.strings["Variant"]? + .localizations?[.german]? + .variations? + .device?[.iPhone]? + .stringUnit + .value + XCTAssertEqual(deviceVariation, "de::Open") + + let substitutionPlural = result.catalog.strings["Variant"]? + .localizations?[.german]? + .substitutions?["count"]? + .variations? + .plural?[.one]? + .stringUnit + .value + XCTAssertEqual(substitutionPlural, "de::One file") + } + + func testFilePlanIsDryRunAndApplyWritesChanges() async throws { + let translator = ClosureTranslator { request in + "de::\(request.text)" + } + + let engine = CatalogTranslationEngine(translator: translator) + let service = CatalogFileTranslationService(engine: engine) + + let tempDirectory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + + let fileURL = tempDirectory.appending(path: "Sample.xcstrings") + let original = try String(contentsOf: InputFiles.dipDictSettings) + try original.write(to: fileURL, atomically: true, encoding: .utf8) + + let plan = try await service.plan(in: [tempDirectory], targetLanguages: [.german], mode: .bestEffort) + XCTAssertFalse(plan.changes.isEmpty) + + let beforeApply = try String(contentsOf: fileURL) + XCTAssertEqual(beforeApply, original) + + let applyReport = try service.apply(plan) + XCTAssertEqual(applyReport.writtenFiles.count, plan.changes.count) + + let afterApply = try String(contentsOf: fileURL) + XCTAssertNotEqual(afterApply, original) + } +} + +private struct ClosureTranslator: CatalogTextTranslator { + let closure: @Sendable (TranslationRequest) async throws -> String + + init(_ closure: @escaping @Sendable (TranslationRequest) async throws -> String) { + self.closure = closure + } + + func translate(_ request: TranslationRequest) async throws -> String { + try await closure(request) + } +} + +private func makeSimpleCatalog() -> StringCatalog { + StringCatalog( + sourceLanguage: .english, + strings: [ + "Hello": StringEntry( + localizations: [ + .english: StringLocalization(stringUnit: StringUnit(state: .translated, value: "Hello")) + ] + ) + ], + version: "1.0" + ) +} + +private func makeComplexCatalog() -> StringCatalog { + let variantSourceVariations = StringVariations( + device: [.iPhone: StringVariation(stringUnit: StringUnit(state: .translated, value: "Open"))], + plural: nil + ) + + let substitutionVariations = StringVariations( + device: nil, + plural: [.one: StringVariation(stringUnit: StringUnit(state: .translated, value: "One file"))] + ) + + return StringCatalog( + sourceLanguage: .english, + strings: [ + "Hello": StringEntry( + localizations: [ + .english: StringLocalization(stringUnit: StringUnit(state: .translated, value: "Hello")) + ] + ), + "List": StringEntry( + localizations: [ + .english: StringLocalization(stringSet: StringSet(state: .translated, values: ["One", "Two"])) + ] + ), + "Already Translated": StringEntry( + localizations: [ + .english: StringLocalization(stringUnit: StringUnit(state: .translated, value: "Already")), + .german: StringLocalization(stringUnit: StringUnit(state: .translated, value: "Bereits da")) + ] + ), + "Variant": StringEntry( + localizations: [ + .english: StringLocalization( + substitutions: [ + "count": StringSubstitution(argNum: 1, formatSpecifier: "%lld", variations: substitutionVariations) + ], + variations: variantSourceVariations + ) + ] + ) + ], + version: "1.0" + ) +} + +enum InputFiles { + static var testResourcesDirectory: URL { + URL(fileURLWithPath: #file).deletingLastPathComponent().appendingPathComponent("TestResources") + } + + static var dipDictSettings: URL { + testResourcesDirectory.appending(path: "DipDictSettings.xcstrings") + } +} diff --git a/Tests/StringCatalogTranslatorTests/TestResources/AppShortcuts.xcstrings b/Tests/CatalogTranslationTests/TestResources/AppShortcuts.xcstrings similarity index 100% rename from Tests/StringCatalogTranslatorTests/TestResources/AppShortcuts.xcstrings rename to Tests/CatalogTranslationTests/TestResources/AppShortcuts.xcstrings diff --git a/Tests/StringCatalogTranslatorTests/TestResources/DipDictSettings.xcstrings b/Tests/CatalogTranslationTests/TestResources/DipDictSettings.xcstrings similarity index 100% rename from Tests/StringCatalogTranslatorTests/TestResources/DipDictSettings.xcstrings rename to Tests/CatalogTranslationTests/TestResources/DipDictSettings.xcstrings diff --git a/Tests/StringCatalogTranslatorTests/TestResources/StringSetUntranslated.xcstrings b/Tests/CatalogTranslationTests/TestResources/StringSetUntranslated.xcstrings similarity index 100% rename from Tests/StringCatalogTranslatorTests/TestResources/StringSetUntranslated.xcstrings rename to Tests/CatalogTranslationTests/TestResources/StringSetUntranslated.xcstrings diff --git a/Tests/StringCatalogTranslatorTests/TestResources/_DipDictSettings.json b/Tests/CatalogTranslationTests/TestResources/_DipDictSettings.json similarity index 100% rename from Tests/StringCatalogTranslatorTests/TestResources/_DipDictSettings.json rename to Tests/CatalogTranslationTests/TestResources/_DipDictSettings.json diff --git a/Tests/StringCatalogTests/StringCatalogCodableTests.swift b/Tests/StringCatalogTests/StringCatalogCodableTests.swift new file mode 100644 index 0000000..4624b68 --- /dev/null +++ b/Tests/StringCatalogTests/StringCatalogCodableTests.swift @@ -0,0 +1,51 @@ +import Foundation +import XCTest +@testable import StringCatalog + +final class StringCatalogCodableTests: XCTestCase { + func testDecodeStringUnitCatalog() throws { + let catalogJson = try String(contentsOf: InputFiles.dipDictSettings) + _ = try JSONDecoder().decode(StringCatalog.self, from: Data(catalogJson.utf8)) + } + + func testDecodeStringSetCatalog() throws { + let catalogJson = try String(contentsOf: InputFiles.appShortcuts) + let catalog = try JSONDecoder().decode(StringCatalog.self, from: Data(catalogJson.utf8)) + + let oneEntry = catalog.strings["${applicationName} insert latest"] + let oneLocalization = oneEntry?.localizations?[.english] + + XCTAssertNil(oneLocalization?.stringUnit) + XCTAssertNotNil(oneLocalization?.stringSet) + } + + func testEncodeSameStringUnitCatalog() throws { + let catalogJson = try String(contentsOf: InputFiles.dipDictSettings) + let catalog = try JSONDecoder().decode(StringCatalog.self, from: Data(catalogJson.utf8)) + let encoded = try catalog.encodePrettyToString() + let decodedAgain = try JSONDecoder().decode(StringCatalog.self, from: Data(encoded.utf8)) + XCTAssertEqual(catalog, decodedAgain) + } + + func testEncodeSameStringSetCatalog() throws { + let catalogJson = try String(contentsOf: InputFiles.appShortcuts) + let catalog = try JSONDecoder().decode(StringCatalog.self, from: Data(catalogJson.utf8)) + let encoded = try catalog.encodePrettyToString() + let decodedAgain = try JSONDecoder().decode(StringCatalog.self, from: Data(encoded.utf8)) + XCTAssertEqual(catalog, decodedAgain) + } +} + +enum InputFiles { + static var testResourcesDirectory: URL { + URL(fileURLWithPath: #file).deletingLastPathComponent().appendingPathComponent("TestResources") + } + + static var dipDictSettings: URL { + testResourcesDirectory.appending(path: "DipDictSettings.xcstrings") + } + + static var appShortcuts: URL { + testResourcesDirectory.appending(path: "AppShortcuts.xcstrings") + } +} diff --git a/Tests/StringCatalogTests/TestResources/AppShortcuts.xcstrings b/Tests/StringCatalogTests/TestResources/AppShortcuts.xcstrings new file mode 100644 index 0000000..f7950eb --- /dev/null +++ b/Tests/StringCatalogTests/TestResources/AppShortcuts.xcstrings @@ -0,0 +1,112 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "${applicationName} insert latest" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringSet" : { + "state" : "translated", + "values" : [ + "${applicationName} Letzte einfügen", + "${applicationName} Letzte", + "${applicationName} Letzte Notiz" + ] + } + }, + "en" : { + "stringSet" : { + "state" : "new", + "values" : [ + "${applicationName} insert latest", + "${applicationName} latest", + "${applicationName} latest note" + ] + } + }, + "fr" : { + "stringSet" : { + "state" : "translated", + "values" : [ + "${applicationName} insérer le dernier", + "${applicationName} le dernier", + "${applicationName} la dernière note" + ] + } + }, + "pl" : { + "stringSet" : { + "state" : "translated", + "values" : [ + "${applicationName} najnowszą notatkę", + "${applicationName} najnowszy", + "${applicationName} wstawić najnowszą" + ] + } + }, + "tr" : { + "stringSet" : { + "state" : "translated", + "values" : [ + "${applicationName} sonuncu", + "${applicationName} en sonuncu", + "${applicationName} son notu yaz" + ] + } + } + } + }, + "${applicationName} record" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringSet" : { + "state" : "translated", + "values" : [ + "${applicationName} aufnehmen", + "${applicationName} fang an" + ] + } + }, + "en" : { + "stringSet" : { + "state" : "new", + "values" : [ + "${applicationName} record", + "${applicationName} start" + ] + } + }, + "fr" : { + "stringSet" : { + "state" : "translated", + "values" : [ + "${applicationName} enregistrer", + "${applicationName} commencez" + ] + } + }, + "pl" : { + "stringSet" : { + "state" : "translated", + "values" : [ + "${applicationName} Nagrywanie", + "${applicationName} start" + ] + } + }, + "tr" : { + "stringSet" : { + "state" : "translated", + "values" : [ + "${applicationName} kaydet", + "${applicationName} kayıt", + "${applicationName} başla" + ] + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Tests/StringCatalogTests/TestResources/DipDictSettings.xcstrings b/Tests/StringCatalogTests/TestResources/DipDictSettings.xcstrings new file mode 100644 index 0000000..48873fa --- /dev/null +++ b/Tests/StringCatalogTests/TestResources/DipDictSettings.xcstrings @@ -0,0 +1,166 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "" : { + + }, + " Click here to go to OpenAI and get your key" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "HERE_IS_SOURCE_LANGUAGE_TRANSLATION" + } + } + } + }, + "*Disk*: %@" : { + + }, + "*Memory*: %@" : { + + }, + "%.2f" : { + + }, + "Animate" : { + + }, + "Background Theme" : { + + }, + "Bring Your Own API Key" : { + "comment" : "This is the title of the list of models that the user can use with their own API key" + }, + "Delete" : { + + }, + "Do you want to delete this model from disk?" : { + + }, + "Do you want to stop downloading this model?" : { + + }, + "Download the model first to activate it" : { + + }, + "Enable GPT post-transcription process" : { + + }, + "Enabled" : { + + }, + "Enter your API Key to enable this feature" : { + + }, + "GPT Model" : { + + }, + "If you use only one language choose that. Otherwise you can choose \"Auto\" to auto-detect it." : { + + }, + "Initial Prompt" : { + + }, + "Input" : { + + }, + "Insert by" : { + + }, + "Keyboard Shortcut" : { + + }, + "Language" : { + + }, + "Launch at login" : { + + }, + "Let us know if you are interested" : { + + }, + "Let us Take Care of it" : { + + }, + "Models we plan to support" : { + + }, + "Nevermind" : { + + }, + "OK" : { + + }, + "OpenAI API Key:" : { + + }, + "Output" : { + + }, + "Play Sounds" : { + + }, + "Save Transcriptions" : { + + }, + "Send the transcript to ChatGPT for context-aware corrections" : { + + }, + "Stop" : { + + }, + "System Prompt" : { + + }, + "Texture" : { + + }, + "Use this model to transcribe" : { + "comment" : "Text to show when the user hovers on activation checkbox for a speech-to-text model" + }, + "Whisper distinguishes itself from traditional voice-to-text models by incorporating characteristics of an LLM model.\nTo enhance accuracy, or make it follow a writing style input an initial prompt.\nKeep in mind that Whisper only considers the first 244 tokens of the prompt." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Whisper unterscheidet sich von herkömmlichen Voice-to-Text-Modellen durch die Einbeziehung von Merkmalen eines LLM-Modells.\nUm die Genauigkeit zu erhöhen, oder damit es einem Schreibstil folgt, geben Sie eine erste Prompt ein.\nBeachten Sie, dass Whisper nur die ersten 244 Token des Prompts berücksichtigt." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Whisper se distingue des modèles traditionnels de synthèse vocale en incorporant les caractéristiques d'un modèle LLM.\nPour améliorer la précision ou faire en sorte qu'il suive un style d'écriture, saisissez une invite initiale.\nGardez à l'esprit que Whisper ne prend en compte que les 244 premiers tokens de l'invite." + } + } + } + }, + "You can match the word as a exact match by choosing `string` or you can use a `regex` pattern. All the matches are replaced by the given input. \nLeave it empty if you want to delete the matches." : { + + }, + "You can start the recording by\n • clicking on the status bar item or\n • using the keyboard shortcut defined below" : { + + }, + "You can subscribe and we'll run the LLM using your prompts." : { + + }, + "You can use your own API key to use the LLM post process feature." : { + + }, + "Your clipboard does not change" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ihre Zwischenablage ändert sich nicht" + } + } + } + }, + "Your clipboard is not affected" : { + + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Tests/StringCatalogTests/TestResources/StringSetUntranslated.xcstrings b/Tests/StringCatalogTests/TestResources/StringSetUntranslated.xcstrings new file mode 100644 index 0000000..06f93da --- /dev/null +++ b/Tests/StringCatalogTests/TestResources/StringSetUntranslated.xcstrings @@ -0,0 +1,35 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "${applicationName} insert latest" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringSet" : { + "state" : "new", + "values" : [ + "${applicationName} insert latest", + "${applicationName} latest", + "${applicationName} latest note" + ] + } + } + } + }, + "${applicationName} record" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringSet" : { + "state" : "new", + "values" : [ + "${applicationName} record", + "${applicationName} start" + ] + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Tests/StringCatalogTests/TestResources/_DipDictSettings.json b/Tests/StringCatalogTests/TestResources/_DipDictSettings.json new file mode 100644 index 0000000..01b06b5 --- /dev/null +++ b/Tests/StringCatalogTests/TestResources/_DipDictSettings.json @@ -0,0 +1,461 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: []" + } + } + } + }, + " Click here to go to OpenAI and get your key" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [HERE_IS_SOURCE_LANGUAGE_TRANSLATION]" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "HERE_IS_SOURCE_LANGUAGE_TRANSLATION" + } + } + } + }, + "*Disk*: %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [*Disk*: %@]" + } + } + } + }, + "*Memory*: %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [*Memory*: %@]" + } + } + } + }, + "%.2f" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [%.2f]" + } + } + } + }, + "Animate" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Animate]" + } + } + } + }, + "Background Theme" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Background Theme]" + } + } + } + }, + "Bring Your Own API Key" : { + "comment" : "This is the title of the list of models that the user can use with their own API key", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Bring Your Own API Key]" + } + } + } + }, + "Delete" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Delete]" + } + } + } + }, + "Do you want to delete this model from disk?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Do you want to delete this model from disk?]" + } + } + } + }, + "Do you want to stop downloading this model?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Do you want to stop downloading this model?]" + } + } + } + }, + "Download the model first to activate it" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Download the model first to activate it]" + } + } + } + }, + "Enable GPT post-transcription process" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Enable GPT post-transcription process]" + } + } + } + }, + "Enabled" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Enabled]" + } + } + } + }, + "Enter your API Key to enable this feature" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Enter your API Key to enable this feature]" + } + } + } + }, + "GPT Model" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [GPT Model]" + } + } + } + }, + "If you use only one language choose that. Otherwise you can choose \"Auto\" to auto-detect it." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [If you use only one language choose that. Otherwise you can choose \"Auto\" to auto-detect it.]" + } + } + } + }, + "Initial Prompt" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Initial Prompt]" + } + } + } + }, + "Input" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Input]" + } + } + } + }, + "Insert by" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Insert by]" + } + } + } + }, + "Keyboard Shortcut" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Keyboard Shortcut]" + } + } + } + }, + "Language" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Language]" + } + } + } + }, + "Launch at login" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Launch at login]" + } + } + } + }, + "Let us know if you are interested" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Let us know if you are interested]" + } + } + } + }, + "Let us Take Care of it" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Let us Take Care of it]" + } + } + } + }, + "Models we plan to support" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Models we plan to support]" + } + } + } + }, + "Nevermind" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Nevermind]" + } + } + } + }, + "OK" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [OK]" + } + } + } + }, + "OpenAI API Key:" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [OpenAI API Key:]" + } + } + } + }, + "Output" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Output]" + } + } + } + }, + "Play Sounds" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Play Sounds]" + } + } + } + }, + "Save Transcriptions" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Save Transcriptions]" + } + } + } + }, + "Send the transcript to ChatGPT for context-aware corrections" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Send the transcript to ChatGPT for context-aware corrections]" + } + } + } + }, + "Stop" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Stop]" + } + } + } + }, + "System Prompt" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [System Prompt]" + } + } + } + }, + "Texture" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Texture]" + } + } + } + }, + "Use this model to transcribe" : { + "comment" : "Text to show when the user hovers on activation checkbox for a speech-to-text model", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Use this model to transcribe]" + } + } + } + }, + "Whisper distinguishes itself from traditional voice-to-text models by incorporating characteristics of an LLM model.\nTo enhance accuracy, or make it follow a writing style input an initial prompt.\nKeep in mind that Whisper only considers the first 244 tokens of the prompt." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Whisper unterscheidet sich von herkömmlichen Voice-to-Text-Modellen durch die Einbeziehung von Merkmalen eines LLM-Modells.\nUm die Genauigkeit zu erhöhen, oder damit es einem Schreibstil folgt, geben Sie eine erste Prompt ein.\nBeachten Sie, dass Whisper nur die ersten 244 Token des Prompts berücksichtigt." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Whisper se distingue des modèles traditionnels de synthèse vocale en incorporant les caractéristiques d'un modèle LLM.\nPour améliorer la précision ou faire en sorte qu'il suive un style d'écriture, saisissez une invite initiale.\nGardez à l'esprit que Whisper ne prend en compte que les 244 premiers tokens de l'invite." + } + } + } + }, + "You can match the word as a exact match by choosing `string` or you can use a `regex` pattern. All the matches are replaced by the given input. \nLeave it empty if you want to delete the matches." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [You can match the word as a exact match by choosing `string` or you can use a `regex` pattern. All the matches are replaced by the given input. \nLeave it empty if you want to delete the matches.]" + } + } + } + }, + "You can start the recording by\n • clicking on the status bar item or\n • using the keyboard shortcut defined below" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [You can start the recording by\n • clicking on the status bar item or\n • using the keyboard shortcut defined below]" + } + } + } + }, + "You can subscribe and we'll run the LLM using your prompts." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [You can subscribe and we'll run the LLM using your prompts.]" + } + } + } + }, + "You can use your own API key to use the LLM post process feature." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [You can use your own API key to use the LLM post process feature.]" + } + } + } + }, + "Your clipboard does not change" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ihre Zwischenablage ändert sich nicht" + } + } + } + }, + "Your clipboard is not affected" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a DeepL translation from StringLanguage(stringValue: \"en\", intValue: nil) to StringLanguage(stringValue: \"de\", intValue: nil). Original text: [Your clipboard is not affected]" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Tests/StringCatalogTranslatorTests/StringCatalogCodableTests.swift b/Tests/StringCatalogTranslatorTests/StringCatalogCodableTests.swift deleted file mode 100644 index 535aa55..0000000 --- a/Tests/StringCatalogTranslatorTests/StringCatalogCodableTests.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// https://github.com/atacan -// 03.05.24 - - -import CustomDump -import XCTest -import Foundation -@testable import StringCatalog - -final class StringCatalogCodableTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testStringUnit() throws { - let catalogJson = try String(contentsOf: InputFiles.DipDictSettings) - let catalog = try JSONDecoder().decode(StringCatalog.self, from: catalogJson.data(using: .utf8)!) - } - - func testStringSet() throws { - let catalogJson = try String(contentsOf: InputFiles.AppShortcuts) - let catalog = try JSONDecoder().decode(StringCatalog.self, from: catalogJson.data(using: .utf8)!) - - let oneOfThem = catalog.strings["${applicationName} insert latest"]! - let oneLocalization = oneOfThem.localizations![StringLanguage.english]! - - XCTAssertNil(oneLocalization.stringUnit) - XCTAssertNotNil(oneLocalization.stringSet) - } - - func testEncodeSameStringUnit() throws { - let catalogJson: String = try String(contentsOf: InputFiles.AppShortcuts) - let catalog: StringCatalog = try JSONDecoder().decode(StringCatalog.self, from: catalogJson.data(using: .utf8)!) - - XCTAssertNoDifference(catalogJson, try catalog.encodePrettyToString()) - } - - func testEncodeSameStringSet() throws { - let catalogJson: String = try String(contentsOf: InputFiles.AppShortcuts) - let catalog: StringCatalog = try JSONDecoder().decode(StringCatalog.self, from: catalogJson.data(using: .utf8)!) - - XCTAssertNoDifference(catalogJson, try catalog.encodePrettyToString()) - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -} - -public class TestingUtility { - - public static func readFile(_ path: String) -> String { - let absolutePath = Bundle.module.url(forResource: path, withExtension: "")! - return try! String(contentsOf: absolutePath, encoding: .utf8) - } - - public static func readBytes(_ path: String) -> Foundation.Data { - let absolutePath = Bundle.module.url(forResource: path, withExtension: "")! - return try! Data(contentsOf: absolutePath) - } -} diff --git a/Tests/StringCatalogTranslatorTests/StringCatalogTranslatorTests.swift b/Tests/StringCatalogTranslatorTests/StringCatalogTranslatorTests.swift deleted file mode 100644 index 7320274..0000000 --- a/Tests/StringCatalogTranslatorTests/StringCatalogTranslatorTests.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// https://github.com/atacan -// 16.03.24 - -import CustomDump -import Dependencies -import StringCatalog -import StringCatalogTranslator -import XCTest - -final class StringCatalogTranslatorTests: XCTestCase { - @Dependency(StringCatalogTranslator.self) var stringCatalogTranslator - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testNoChange() async throws { - let catalogJson = try String(contentsOf: InputFiles.DipDictSettings) - - let catalog = try JSONDecoder().decode(StringCatalog.self, from: catalogJson.data(using: .utf8)!) - enum LocalError: Error { case failToTranslate } - try await withDependencies { - $0.stringCatalogTranslator.translateWithDeepL = { text, _, _, _ in - throw LocalError.failToTranslate - } - } operation: { - let translatedCatalog = try await stringCatalogTranslator.translateCatalog(catalog, to: .german, translationService: .deepL) - - XCTAssertNoDifference(catalog, translatedCatalog) - XCTAssertNoDifference(catalogJson, try catalog.encodePrettyToString()) - XCTAssertNoDifference(catalogJson, try translatedCatalog.encodePrettyToString()) - } - } - - func testNotTranslateStringSet() async throws { - let catalogJson = try String(contentsOf: InputFiles.StringSetUntranslated) - let catalog = try JSONDecoder().decode(StringCatalog.self, from: catalogJson.data(using: .utf8)!) - - try await withDependencies { - $0.stringCatalogTranslator = .previewValue - } operation: { - let translatedCatalog = try await stringCatalogTranslator.translateCatalog(catalog, to: .german, translationService: .deepL) - - XCTAssertNoDifference(catalog, translatedCatalog) - XCTAssertNoDifference(catalogJson, try catalog.encodePrettyToString()) - XCTAssertNoDifference(catalogJson, try translatedCatalog.encodePrettyToString()) - } - } - - func testDontTouchTranslatedStringSet() async throws { - let catalogJson = try String(contentsOf: InputFiles.AppShortcuts) - let catalog = try JSONDecoder().decode(StringCatalog.self, from: catalogJson.data(using: .utf8)!) - - try await withDependencies { - $0.stringCatalogTranslator = .previewValue - } operation: { - let translatedCatalog = try await stringCatalogTranslator.translateCatalog(catalog, to: .german, translationService: .deepL) - - XCTAssertNoDifference(catalog, translatedCatalog) - XCTAssertNoDifference(catalogJson, try catalog.encodePrettyToString()) - XCTAssertNoDifference(catalogJson, try translatedCatalog.encodePrettyToString()) - } - } - - func testDeepL() async throws { - let catalogJson = try String(contentsOf: InputFiles.DipDictSettings) - - let catalog = try JSONDecoder().decode(StringCatalog.self, from: catalogJson.data(using: .utf8)!) - - try await withDependencies { - $0.stringCatalogTranslator = .previewValue - } operation: { - let targetLanguage = StringLanguage.german - let translatedCatalog = try await stringCatalogTranslator.translateCatalog(catalog, to: targetLanguage, translationService: .deepL) - - // every string has localization - for (textToTranslate, stringEntry) in translatedCatalog.strings { - guard let localizations = stringEntry.localizations else { - fatalError() - } - // every localization has entry for target language - guard let localization = localizations[targetLanguage] else { - fatalError() - } - - // the existing translation stayed the same - if let existing = catalog.strings[textToTranslate]?.localizations?[targetLanguage]?.stringUnit?.value { - XCTAssertEqual(localization.stringUnit?.value, existing) - } - - } - - // same amount of keys - XCTAssertEqual(catalog.strings.keys.count, translatedCatalog.strings.keys.count) - - let translatedCatalogPath = InputFiles.testResourcesDirectory.appending(component: "_DipDictSettings.json").path() - // let translatedCatalogPath = InputFiles.testResourcesDirectory.appending(component: "_DipDictSettings.xcstrings").path() - try translatedCatalog.encodePrettyToString().write(toFile: translatedCatalogPath, atomically: true, encoding: .utf8) - - } - } -} - -enum InputFiles { - - static var testResourcesDirectory: URL { - let currentFile = URL(fileURLWithPath: #file).deletingLastPathComponent() - return currentFile.appendingPathComponent("TestResources") - } - - static var DipDictSettings: URL { - return Self.testResourcesDirectory.appending(component: "DipDictSettings.xcstrings") - } - - static var AppShortcuts: URL { - return Self.testResourcesDirectory.appending(component: "AppShortcuts.xcstrings") - } - - static var StringSetUntranslated: URL { - return Self.testResourcesDirectory.appending(component: "StringSetUntranslated.xcstrings") - } - -} From 45eb55c1553c1c249cdbd53cea5f75c7b8606198 Mon Sep 17 00:00:00 2001 From: atacan Date: Sat, 21 Feb 2026 12:12:22 +0100 Subject: [PATCH 03/11] rename --- Package.swift | 4 +- README.md | 2 +- .../CatalogTranslationLLM/LLMTranslator.swift | 71 +++++++++++++++++++ .../LLMExampleTranslator.swift | 69 ------------------ 4 files changed, 74 insertions(+), 72 deletions(-) create mode 100644 Sources/CatalogTranslationLLM/LLMTranslator.swift delete mode 100644 Sources/CatalogTranslationLLMExample/LLMExampleTranslator.swift diff --git a/Package.swift b/Package.swift index d954f89..0bea97f 100644 --- a/Package.swift +++ b/Package.swift @@ -15,7 +15,7 @@ let package = Package( products: [ .library(name: "StringCatalog", targets: ["StringCatalog"]), .library(name: "CatalogTranslation", targets: ["CatalogTranslation"]), - .library(name: "CatalogTranslationLLMExample", targets: ["CatalogTranslationLLMExample"]), + .library(name: "CatalogTranslationLLM", targets: ["CatalogTranslationLLM"]), ], dependencies: [], targets: [ @@ -27,7 +27,7 @@ let package = Package( ] ), .target( - name: "CatalogTranslationLLMExample", + name: "CatalogTranslationLLM", dependencies: [ .CatalogTranslation, ] diff --git a/README.md b/README.md index 348dc41..65b9830 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ - `StringCatalog`: Codable model types for Xcode string catalogs. - `CatalogTranslation`: Translation engine + file planning/apply API. -- `CatalogTranslationLLMExample`: Optional example adapter for LLM prompt templating. +- `CatalogTranslationLLM`: LLM adapter with customizable prompt templates. ## Quick Start diff --git a/Sources/CatalogTranslationLLM/LLMTranslator.swift b/Sources/CatalogTranslationLLM/LLMTranslator.swift new file mode 100644 index 0000000..d338f5c --- /dev/null +++ b/Sources/CatalogTranslationLLM/LLMTranslator.swift @@ -0,0 +1,71 @@ +import CatalogTranslation +import Foundation +import StringCatalog + +public struct PromptTemplate: Sendable { + public var makeSystemPrompt: @Sendable (TranslationRequest) -> String + public var makeUserPrompt: @Sendable (TranslationRequest) -> String + + public init( + makeSystemPrompt: @escaping @Sendable (TranslationRequest) -> String, + makeUserPrompt: @escaping @Sendable (TranslationRequest) -> String + ) { + self.makeSystemPrompt = makeSystemPrompt + self.makeUserPrompt = makeUserPrompt + } + + public static let appLocalizationDefault = Self( + makeSystemPrompt: { request in + let source = request.sourceLanguage.englishDisplayName + let target = request.targetLanguage.englishDisplayName + return """ + You're a skilled translator with extensive experience in translating \(source) text to \(target) while maintaining the original formatting, especially for markdown. Your expertise allows you to ensure that nuances in meaning and cultural context are carefully preserved in the translation, making it accessible and appropriate for \(target) speakers. + + Your task is to translate a piece of UI of an iOS app written in \(source) to \(target) language. The text was taken from Xcode string catalog. + If there is markdown formatting, ensure that the markdown formatting remains intact. + Keep the placeholder values such as ["@", "lld", ".2f", "1$@", "2$@", "3$@", "1$lld", "2$lld"] used in the string catalogs at the meaningfully correct place in the translation. + + Please keep in mind any specific context or tone that should be maintained during the translation, particularly regarding cultural references or idiomatic expressions. The author may add additional context on where the text is used in the UI. Use that information to choose the most suitable translation if there are multiple options. + + For further clarity, here's how I would like the output formatted: + - For headings, translate the text while maintaining the heading level (e.g., # for H1, ## for H2). + - For lists, keep the bullet points or numbering intact while translating the content. + - For emphasized text (bold or italics), use the appropriate markdown syntax in \(target). + - Keep the line breaks intact. + + Only output the translation without backticks. + """ + }, + makeUserPrompt: { request in + let additionalContext = request.developerComment.map { " Additional context from the author: \($0)." } ?? "" + return """ + The text I need you to translate is below delimited by triple backticks.\(additionalContext) + ``` + \(request.text) + ``` + """ + } + ) +} + +public struct LLMTranslator: CatalogTextTranslator, Sendable { + private let template: PromptTemplate + private let complete: @Sendable (_ request: TranslationRequest, _ systemPrompt: String, _ userPrompt: String) async throws -> String + + public init( + template: PromptTemplate = .appLocalizationDefault, + complete: @escaping @Sendable (_ request: TranslationRequest, _ systemPrompt: String, _ userPrompt: String) async throws -> String + ) { + self.template = template + self.complete = complete + } + + public func translate(_ request: TranslationRequest) async throws -> String { + let systemPrompt = template.makeSystemPrompt(request) + let userPrompt = template.makeUserPrompt(request) + return try await complete(request, systemPrompt, userPrompt) + } +} + +@available(*, deprecated, renamed: "LLMTranslator") +public typealias LLMExampleTranslator = LLMTranslator diff --git a/Sources/CatalogTranslationLLMExample/LLMExampleTranslator.swift b/Sources/CatalogTranslationLLMExample/LLMExampleTranslator.swift deleted file mode 100644 index dd03bd6..0000000 --- a/Sources/CatalogTranslationLLMExample/LLMExampleTranslator.swift +++ /dev/null @@ -1,69 +0,0 @@ -import CatalogTranslation -import Foundation -import StringCatalog - -public struct PromptTemplate: Sendable { - public var makeSystemPrompt: @Sendable (TranslationRequest) -> String - public var makeUserPrompt: @Sendable (TranslationRequest) -> String - - public init( - makeSystemPrompt: @escaping @Sendable (TranslationRequest) -> String, - makeUserPrompt: @escaping @Sendable (TranslationRequest) -> String - ) { - self.makeSystemPrompt = makeSystemPrompt - self.makeUserPrompt = makeUserPrompt - } - - public static let appLocalizationDefault = Self( - makeSystemPrompt: { request in - let source = request.sourceLanguage.englishDisplayName - let target = request.targetLanguage.englishDisplayName - return """ - You are an expert app localization translator. - Translate from \(source) to \(target). - Preserve placeholders and markdown formatting. - Return only the translated text. - """ - }, - makeUserPrompt: { request in - let context = request.developerComment.map { "Context: \($0)\n" } ?? "" - return """ - \(context)String key: \(request.stringKey) - Segment: \(segmentDescription(request.segment)) - - Text: - \(request.text) - """ - } - ) -} - -public struct LLMExampleTranslator: CatalogTextTranslator, Sendable { - private let template: PromptTemplate - private let complete: @Sendable (_ request: TranslationRequest, _ systemPrompt: String, _ userPrompt: String) async throws -> String - - public init( - template: PromptTemplate = .appLocalizationDefault, - complete: @escaping @Sendable (_ request: TranslationRequest, _ systemPrompt: String, _ userPrompt: String) async throws -> String - ) { - self.template = template - self.complete = complete - } - - public func translate(_ request: TranslationRequest) async throws -> String { - let systemPrompt = template.makeSystemPrompt(request) - let userPrompt = template.makeUserPrompt(request) - return try await complete(request, systemPrompt, userPrompt) - } -} - -private func segmentDescription(_ segment: TranslationSegment) -> String { - switch segment { - case .stringUnit: - return "stringUnit" - case .stringSet(let index): - return "stringSet[\(index)]" - case .variation(let path): - return path - } -} From e8cd5bf0bca66abc5f5ce24e55b0c849195a3db3 Mon Sep 17 00:00:00 2001 From: atacan Date: Sat, 21 Feb 2026 13:09:01 +0100 Subject: [PATCH 04/11] example --- examples/.env.example | 1 + examples/.gitignore | 4 + examples/Package.swift | 31 ++++ examples/README.md | 35 ++++ .../TranslateCatalogWithOpenAI/main.swift | 149 ++++++++++++++++++ 5 files changed, 220 insertions(+) create mode 100644 examples/.env.example create mode 100644 examples/.gitignore create mode 100644 examples/Package.swift create mode 100644 examples/README.md create mode 100644 examples/Sources/TranslateCatalogWithOpenAI/main.swift diff --git a/examples/.env.example b/examples/.env.example new file mode 100644 index 0000000..11b2b32 --- /dev/null +++ b/examples/.env.example @@ -0,0 +1 @@ +OPENAI_API_KEY=your_api_key_here diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..b7f4aaa --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,4 @@ +.build/ +.swiftpm/ +.env +Package.resolved diff --git a/examples/Package.swift b/examples/Package.swift new file mode 100644 index 0000000..60f9db2 --- /dev/null +++ b/examples/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 6.1 + +import PackageDescription + +let package = Package( + name: "StringCatalogKitExamples", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable( + name: "TranslateCatalogWithOpenAI", + targets: ["TranslateCatalogWithOpenAI"] + ) + ], + dependencies: [ + .package(path: ".."), + .package(url: "https://github.com/atacan/UsefulThings.git", branch: "main") + ], + targets: [ + .executableTarget( + name: "TranslateCatalogWithOpenAI", + dependencies: [ + .product(name: "CatalogTranslation", package: "StringCatalogKit"), + .product(name: "CatalogTranslationLLM", package: "StringCatalogKit"), + .product(name: "StringCatalog", package: "StringCatalogKit"), + .product(name: "UsefulThings", package: "UsefulThings") + ] + ) + ] +) diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..31d2d16 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,35 @@ +# StringCatalogKit OpenAI Example + +This is a standalone executable Swift package that: + +- depends on `StringCatalogKit` via a local relative path (`..`) +- uses `CatalogTranslationLLM.LLMTranslator` with default prompts +- makes a minimal `URLSession` request to OpenAI +- reads `OPENAI_API_KEY` from `.env` using `UsefulThings.getEnvironmentVariable` + +## Setup + +1. Create a `.env` file in this folder: + +```dotenv +OPENAI_API_KEY=your_api_key_here +``` + +2. Build and run: + +```bash +cd examples +swift run TranslateCatalogWithOpenAI [model] +``` + +Example: + +```bash +swift run TranslateCatalogWithOpenAI ../Tests/StringCatalogTests/TestResources/StringSetUntranslated.xcstrings de gpt-4.1-mini +``` + +The executable writes: + +`..translated.xcstrings` + +next to the input file. diff --git a/examples/Sources/TranslateCatalogWithOpenAI/main.swift b/examples/Sources/TranslateCatalogWithOpenAI/main.swift new file mode 100644 index 0000000..9b8ae26 --- /dev/null +++ b/examples/Sources/TranslateCatalogWithOpenAI/main.swift @@ -0,0 +1,149 @@ +import CatalogTranslation +import CatalogTranslationLLM +import Foundation +import StringCatalog +import UsefulThings + +@main +struct TranslateCatalogWithOpenAI { + static func main() async { + do { + try await run() + } catch { + fputs("Error: \(error.localizedDescription)\n", stderr) + exit(1) + } + } + + private static func run() async throws { + let arguments = CommandLine.arguments + guard arguments.count >= 3 else { + printUsage() + throw ExampleError.invalidArguments + } + + let inputPath = arguments[1] + let targetLanguage = LanguageCode(rawValue: arguments[2]) + let model = arguments.count >= 4 ? arguments[3] : "gpt-4.1-mini" + + let currentDirectory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + let inputURL = URL(fileURLWithPath: inputPath, relativeTo: currentDirectory).standardizedFileURL + let envFileURL = currentDirectory.appendingPathComponent(".env") + + guard let apiKey = getEnvironmentVariable("OPENAI_API_KEY", from: envFileURL), !apiKey.isEmpty else { + throw ExampleError.missingAPIKey(envFileURL.path) + } + + let catalog = try StringCatalog(contentsOf: inputURL) + let openAIClient = OpenAIClient(apiKey: apiKey, model: model) + + let translator = LLMTranslator { _, systemPrompt, userPrompt in + try await openAIClient.complete(systemPrompt: systemPrompt, userPrompt: userPrompt) + } + + let engine = CatalogTranslationEngine(translator: translator) + let result = try await engine.translateCatalog(catalog, to: targetLanguage) + + let outputFileName = "\(inputURL.deletingPathExtension().lastPathComponent).\(targetLanguage.rawValue).translated.xcstrings" + let outputURL = inputURL.deletingLastPathComponent().appendingPathComponent(outputFileName) + try result.catalog.encodePrettyToString().write(to: outputURL, atomically: true, encoding: .utf8) + + print("Wrote translated catalog to: \(outputURL.path)") + print("Stats: attempted=\(result.report.stats.attemptedSegments), translated=\(result.report.stats.translatedSegments), failed=\(result.report.stats.failedSegments), skipped=\(result.report.stats.skippedSegments)") + } + + private static func printUsage() { + print(""" + Usage: + swift run TranslateCatalogWithOpenAI [model] + + Example: + swift run TranslateCatalogWithOpenAI ../Tests/StringCatalogTests/TestResources/StringSetUntranslated.xcstrings de gpt-4.1-mini + """) + } +} + +private struct OpenAIClient: Sendable { + let apiKey: String + let model: String + + func complete(systemPrompt: String, userPrompt: String) async throws -> String { + let endpoint = URL(string: "https://api.openai.com/v1/chat/completions")! + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + let payload = ChatCompletionsRequest( + model: model, + temperature: 0.2, + messages: [ + .init(role: "system", content: systemPrompt), + .init(role: "user", content: userPrompt) + ] + ) + request.httpBody = try JSONEncoder().encode(payload) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw ExampleError.invalidHTTPResponse + } + + guard (200 ..< 300).contains(httpResponse.statusCode) else { + let responseBody = String(data: data, encoding: .utf8) ?? "" + throw ExampleError.openAIStatus(httpResponse.statusCode, responseBody) + } + + let decoded = try JSONDecoder().decode(ChatCompletionsResponse.self, from: data) + guard let content = decoded.choices.first?.message.content, !content.isEmpty else { + throw ExampleError.emptyModelResponse + } + return content + } +} + +private struct ChatCompletionsRequest: Encodable { + struct Message: Encodable { + let role: String + let content: String + } + + let model: String + let temperature: Double + let messages: [Message] +} + +private struct ChatCompletionsResponse: Decodable { + struct Choice: Decodable { + struct Message: Decodable { + let content: String? + } + + let message: Message + } + + let choices: [Choice] +} + +private enum ExampleError: LocalizedError { + case invalidArguments + case missingAPIKey(String) + case invalidHTTPResponse + case openAIStatus(Int, String) + case emptyModelResponse + + var errorDescription: String? { + switch self { + case .invalidArguments: + return "Invalid arguments." + case .missingAPIKey(let envPath): + return "Missing OPENAI_API_KEY. Add it to process env vars or \(envPath)." + case .invalidHTTPResponse: + return "OpenAI request did not return an HTTP response." + case .openAIStatus(let statusCode, let body): + return "OpenAI request failed with status \(statusCode): \(body)" + case .emptyModelResponse: + return "OpenAI response did not contain a completion." + } + } +} From c592eb353ba8ba85b6364c37a452abfa07d33141 Mon Sep 17 00:00:00 2001 From: atacan Date: Sat, 21 Feb 2026 13:18:59 +0100 Subject: [PATCH 05/11] secret check --- .github/workflows/secret-scan.yml | 33 +++++++++++++++++++++++ .gitignore | 11 ++++++++ .gitleaks.toml | 45 +++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 28 +++++++++++++++++++ .trufflehog-exclude-paths.txt | 29 ++++++++++++++++++++ 5 files changed, 146 insertions(+) create mode 100644 .github/workflows/secret-scan.yml create mode 100644 .gitleaks.toml create mode 100644 .pre-commit-config.yaml create mode 100644 .trufflehog-exclude-paths.txt diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..bf97a18 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,33 @@ +name: Secret Scanning + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + gitleaks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_CONFIG: .gitleaks.toml + + trufflehog: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Trufflehog + uses: trufflesecurity/trufflehog@main + with: + extra_args: --only-verified --exclude-paths=.trufflehog-exclude-paths.txt diff --git a/.gitignore b/.gitignore index 0023a53..f2ca357 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,14 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +# Sensitive files +.env +.env.* +*.key +*.pem +*.p12 +credentials.json +# Secret scanning reports +gitleaks-report.json +trufflehog-report.json +.claude/settings.local.json diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..9c2d35c --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,45 @@ +# Gitleaks configuration +# https://github.com/gitleaks/gitleaks#configuration + +title = "Gitleaks config" + +# Extend the default rules (don't replace them) +[extend] +useDefault = true + +# Paths to exclude from scanning +# These are dependency/build directories that contain +# test fixtures with fake secrets, not real leaks. +[allowlist] + description = "Global allowlist" + paths = [ + # Swift Package Manager + '''\.build/''', + '''\.swiftpm/''', + + # CocoaPods + '''Pods/''', + + # Dependency checkouts and caches + '''Carthage/Checkouts/''', + '''vendor/''', + '''node_modules/''', + + # Build artifacts + '''DerivedData/''', + '''build/''', + '''dist/''', + + # Package lock files (contain hashes, not secrets) + '''Package\.resolved$''', + '''Podfile\.lock$''', + '''package-lock\.json$''', + '''yarn\.lock$''', + '''pnpm-lock\.yaml$''', + '''Gemfile\.lock$''', + + # Test fixtures that intentionally contain fake secrets + '''(test|tests|spec|specs|__tests__)/.*fixtures?/''', + '''(test|tests|spec|specs|__tests__)/.*mocks?/''', + '''(test|tests|spec|specs|__tests__)/.*stubs?/''', + ] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e281d22 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: + # ------------------------------------------------------- + # Gitleaks — pattern-based secret detection + # Uses the official pre-commit hook from the gitleaks repo. + # The .gitleaks.toml config handles path exclusions. + # ------------------------------------------------------- + - repo: https://github.com/gitleaks/gitleaks + rev: v8.21.2 + hooks: + - id: gitleaks + args: ['--config=.gitleaks.toml'] + + # ------------------------------------------------------- + # Trufflehog — verification-based secret detection + # Uses a LOCAL hook calling the already-installed binary. + # This avoids the known issue where the repo-based hook + # tries to 'go install' from source and fails. + # The .trufflehog-exclude-paths.txt handles exclusions. + # ------------------------------------------------------- + - repo: local + hooks: + - id: trufflehog + name: trufflehog (local) + language: system + entry: bash -c '/opt/homebrew/bin/trufflehog git file://. --since-commit HEAD --results=verified,unknown --exclude-paths=.trufflehog-exclude-paths.txt --fail' + stages: [pre-commit] + pass_filenames: false + always_run: true diff --git a/.trufflehog-exclude-paths.txt b/.trufflehog-exclude-paths.txt new file mode 100644 index 0000000..09c5570 --- /dev/null +++ b/.trufflehog-exclude-paths.txt @@ -0,0 +1,29 @@ +# Trufflehog path exclusions +# One path/pattern per line (glob syntax) + +# Swift Package Manager +.build/ +.swiftpm/ + +# CocoaPods +Pods/ + +# Carthage +Carthage/Checkouts/ + +# Other dependency/vendor dirs +vendor/ +node_modules/ + +# Build artifacts +DerivedData/ +build/ +dist/ + +# Lock files +Package.resolved +Podfile.lock +package-lock.json +yarn.lock +pnpm-lock.yaml +Gemfile.lock From 7df70026971069a98f8a6282c0d2a6e84db0a695 Mon Sep 17 00:00:00 2001 From: atacan Date: Sat, 21 Feb 2026 19:48:43 +0100 Subject: [PATCH 06/11] no history --- README.md | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/README.md b/README.md index 65b9830..c858c03 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,4 @@ print(plan.items.filter(\.changed).count) // Explicitly apply: let applyReport = try service.apply(plan) print(applyReport.writtenFiles) -``` - -## Migration (Old -> New) - -- Removed `swift-dependencies` usage from translation APIs. -- Removed built-in DeepL/OpenAI hard-coded paths from core. -- Replaced static `TranslateStringCatalog` entry points with instance APIs: - - `CatalogTranslationEngine` - - `CatalogFileTranslationService` -- Replaced closed `StringLanguage` enum in core storage with flexible `LanguageCode`. +``` \ No newline at end of file From 98592975e504acdcfccb446fb87d00b90d8a9c10 Mon Sep 17 00:00:00 2001 From: atacan Date: Sat, 21 Feb 2026 20:32:21 +0100 Subject: [PATCH 07/11] Prepare repo for open source release --- Makefile | 16 ++++++++++++++++ README.md | 22 +++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5e00a08 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +check_uncommitted: + @if git diff-index --quiet HEAD --; then \ + echo '\033[32mNo uncommitted changes found.\033[0m'; \ + else \ + echo '\033[31mUncommitted changes detected. Aborting.\033[0m'; \ + exit 1; \ + fi + +# merge the current branch to main and push +merge_main: check_uncommitted + # get the name of the current branch + $(eval BRANCH := $(shell git branch --show-current)) + # merge the current branch to main + git checkout main + git merge $(BRANCH) + git push \ No newline at end of file diff --git a/README.md b/README.md index c858c03..ffbad13 100644 --- a/README.md +++ b/README.md @@ -45,4 +45,24 @@ print(plan.items.filter(\.changed).count) // Explicitly apply: let applyReport = try service.apply(plan) print(applyReport.writtenFiles) -``` \ No newline at end of file +``` + +## LLM Usage + +`CatalogTranslationLLM` provides `LLMTranslator`, which adapts system/user prompts to any model provider: + +```swift +import CatalogTranslation +import CatalogTranslationLLM + +let translator = LLMTranslator { _, systemPrompt, userPrompt in + // Call your LLM API here and return translated text. + try await myLLMClient.complete(system: systemPrompt, user: userPrompt) +} + +let engine = CatalogTranslationEngine(translator: translator) +``` + +For a full runnable example with OpenAI integration, see: +- `examples/README.md` +- `examples/Sources/TranslateCatalogWithOpenAI/main.swift` From fb9b1629475bb7cd1939d4e55af637e01b62abc8 Mon Sep 17 00:00:00 2001 From: atacan Date: Sun, 22 Feb 2026 15:50:37 +0100 Subject: [PATCH 08/11] test skip existing translation --- .../CatalogTranslationEngine.swift | 31 +++++++- .../CatalogTranslationEngineTests.swift | 79 +++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/Sources/CatalogTranslation/CatalogTranslationEngine.swift b/Sources/CatalogTranslation/CatalogTranslationEngine.swift index 765ee0d..32bac3e 100644 --- a/Sources/CatalogTranslation/CatalogTranslationEngine.swift +++ b/Sources/CatalogTranslation/CatalogTranslationEngine.swift @@ -142,7 +142,7 @@ private extension CatalogTranslationEngine { for (stringKey, stringEntry) in catalog.strings { let sourceLocalization = stringEntry.localizations?[sourceLanguage] - let targetLocalization = stringEntry.localizations?[targetLanguage] + let targetLocalization = localizedValue(for: targetLanguage, in: stringEntry.localizations) if targetLocalization?.stringUnit == nil { let text = sourceLocalization?.stringUnit?.value ?? stringKey @@ -315,7 +315,8 @@ private extension CatalogTranslationEngine { } var localizations = entry.localizations ?? [:] - var targetLocalization = localizations[targetLanguage] ?? StringLocalization() + let targetLocalizationKey = canonicalLocalizationKey(for: targetLanguage, in: localizations) ?? targetLanguage + var targetLocalization = localizations[targetLocalizationKey] ?? StringLocalization() switch segment { case .stringUnit: @@ -353,11 +354,35 @@ private extension CatalogTranslationEngine { ) } - localizations[targetLanguage] = targetLocalization + localizations[targetLocalizationKey] = targetLocalization entry.localizations = localizations catalog.strings[stringKey] = entry } + func localizedValue( + for language: LanguageCode, + in localizations: [LanguageCode: StringLocalization]? + ) -> StringLocalization? { + guard let localizations else { + return nil + } + + return localizations[language] ?? localizations[canonicalLocalizationKey(for: language, in: localizations) ?? language] + } + + func canonicalLocalizationKey( + for language: LanguageCode, + in localizations: [LanguageCode: StringLocalization] + ) -> LanguageCode? { + localizations.keys.first { localizedKey in + languageCodesMatch(localizedKey, language) + } + } + + func languageCodesMatch(_ lhs: LanguageCode, _ rhs: LanguageCode) -> Bool { + lhs.rawValue.caseInsensitiveCompare(rhs.rawValue) == .orderedSame + } + func applyVariation( translatedText: String, path: InternalVariationPath, diff --git a/Tests/CatalogTranslationTests/CatalogTranslationEngineTests.swift b/Tests/CatalogTranslationTests/CatalogTranslationEngineTests.swift index 5b13b58..97f1900 100644 --- a/Tests/CatalogTranslationTests/CatalogTranslationEngineTests.swift +++ b/Tests/CatalogTranslationTests/CatalogTranslationEngineTests.swift @@ -80,6 +80,73 @@ final class CatalogTranslationEngineTests: XCTestCase { XCTAssertEqual(substitutionPlural, "de::One file") } + func testSkipsStringUnitWhenTargetTranslationAlreadyExists() async throws { + let recorder = TranslationRequestRecorder() + let translator = ClosureTranslator { request in + await recorder.record(request) + return "de::\(request.text)" + } + + let engine = CatalogTranslationEngine(translator: translator) + let catalog = StringCatalog( + sourceLanguage: .english, + strings: [ + "Hello": StringEntry( + localizations: [ + .english: StringLocalization(stringUnit: StringUnit(state: .translated, value: "Hello")), + .german: StringLocalization(stringUnit: StringUnit(state: .translated, value: "Hallo")) + ] + ) + ], + version: "1.0" + ) + + let result = try await engine.translateCatalog(catalog, to: .german, mode: .bestEffort) + let recordedCount = await recorder.count + + XCTAssertEqual(result.report.stats.attemptedSegments, 0) + XCTAssertEqual(result.report.stats.translatedSegments, 0) + XCTAssertEqual(recordedCount, 0) + XCTAssertEqual( + result.catalog.strings["Hello"]?.localizations?[.german]?.stringUnit?.value, + "Hallo" + ) + } + + func testMatchesTargetLanguageCaseInsensitiveAndDoesNotCreateDuplicateLocalizationKey() async throws { + let recorder = TranslationRequestRecorder() + let translator = ClosureTranslator { request in + await recorder.record(request) + return "pt::\(request.text)" + } + + let engine = CatalogTranslationEngine(translator: translator) + let lowercaseBrazilianPortuguese = LanguageCode(rawValue: "pt-br") + let catalog = StringCatalog( + sourceLanguage: .english, + strings: [ + "Hello": StringEntry( + localizations: [ + .english: StringLocalization(stringUnit: StringUnit(state: .translated, value: "Hello")), + .portugueseBrazil: StringLocalization(stringUnit: StringUnit(state: .translated, value: "Olá")) + ] + ) + ], + version: "1.0" + ) + + let result = try await engine.translateCatalog(catalog, to: lowercaseBrazilianPortuguese, mode: .bestEffort) + let recordedCount = await recorder.count + + XCTAssertEqual(result.report.stats.attemptedSegments, 0) + XCTAssertEqual(recordedCount, 0) + + let localizations = try XCTUnwrap(result.catalog.strings["Hello"]?.localizations) + XCTAssertTrue(localizations.keys.contains(.portugueseBrazil)) + XCTAssertFalse(localizations.keys.contains(lowercaseBrazilianPortuguese)) + XCTAssertEqual(localizations[.portugueseBrazil]?.stringUnit?.value, "Olá") + } + func testFilePlanIsDryRunAndApplyWritesChanges() async throws { let translator = ClosureTranslator { request in "de::\(request.text)" @@ -122,6 +189,18 @@ private struct ClosureTranslator: CatalogTextTranslator { } } +private actor TranslationRequestRecorder { + private(set) var requests: [TranslationRequest] = [] + + func record(_ request: TranslationRequest) { + requests.append(request) + } + + var count: Int { + requests.count + } +} + private func makeSimpleCatalog() -> StringCatalog { StringCatalog( sourceLanguage: .english, From eae11d4671551915a35b533dc40f27e15c244a1d Mon Sep 17 00:00:00 2001 From: atacan Date: Sun, 22 Feb 2026 15:53:46 +0100 Subject: [PATCH 09/11] comments for actor AsyncSemaphore --- Sources/CatalogTranslation/CatalogTranslationEngine.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/CatalogTranslation/CatalogTranslationEngine.swift b/Sources/CatalogTranslation/CatalogTranslationEngine.swift index 32bac3e..4a6124d 100644 --- a/Sources/CatalogTranslation/CatalogTranslationEngine.swift +++ b/Sources/CatalogTranslation/CatalogTranslationEngine.swift @@ -22,6 +22,8 @@ public struct CatalogTranslationEngine: Sendable { var failures = [TranslationFailure]() var successfulTranslations = [(job: Job, translatedText: String)]() + // `withThrowingTaskGroup` can start all jobs at once. This semaphore enforces the + // user-configured cap on in-flight translation requests (API rate limits / resource use). let semaphore = AsyncSemaphore(value: options.maxConcurrentRequests) do { @@ -431,6 +433,8 @@ private extension CatalogTranslationEngine { } } +// Actor-backed semaphore used to safely coordinate permits across many child tasks. +// This is only for throttling concurrent translation requests. private actor AsyncSemaphore { private var value: Int private var waiters: [CheckedContinuation] = [] From cba7879e5677e2e5daa41bcba2a0ef0e780bf1c1 Mon Sep 17 00:00:00 2001 From: atacan Date: Sat, 28 Feb 2026 18:48:29 +0100 Subject: [PATCH 10/11] Add MIT License to the project --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c26ddd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 atacan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 14060c061a0a988b08614a842e1611c3972c4b09 Mon Sep 17 00:00:00 2001 From: atacan Date: Sat, 28 Feb 2026 18:51:19 +0100 Subject: [PATCH 11/11] Add Swift version and platform badges to README Added badges for Swift version and platform support. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index ffbad13..7db135d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # StringCatalogKit +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fatacan%2FStringCatalogKit%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/atacan/StringCatalogKit) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fatacan%2FStringCatalogKit%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/atacan/StringCatalogKit) + `StringCatalogKit` provides model types for `.xcstrings` files and a provider-agnostic translation engine. ## Products