diff --git a/Package.resolved b/Package.resolved index 41da94a..3593e8e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -27,15 +27,6 @@ "version" : "0.8.1" } }, - { - "identity" : "plot", - "kind" : "remoteSourceControl", - "location" : "https://github.com/JohnSundell/Plot.git", - "state" : { - "revision" : "271926b4413fe868739d99f5eadcf2bd6cd62fb8", - "version" : "0.14.0" - } - }, { "identity" : "sqlite.swift", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index dd93d82..cd8cea5 100644 --- a/Package.swift +++ b/Package.swift @@ -39,7 +39,6 @@ let package = Package( .package(url: "https://github.com/apple/swift-argument-parser.git", .upToNextMajor(from: "1.5.1")), .package(url: "https://github.com/apple/swift-syntax.git", .upToNextMajor(from: "509.0.0")), .package(url: "https://github.com/CoreOffice/XMLCoder.git", .upToNextMajor(from: "0.17.1")), - .package(url: "https://github.com/JohnSundell/Plot.git", .upToNextMajor(from: "0.14.0")), .package(url: "https://github.com/alexisakers/HTMLString.git", .upToNextMajor(from: "6.0.0")), .package(url: "https://github.com/stephencelis/SQLite.swift.git", .upToNextMajor(from: "0.15.4")), ], @@ -66,7 +65,6 @@ let package = Package( dependencies: [ "TranslationCatalog", .product(name: "XMLCoder", package: "XMLCoder"), - .product(name: "Plot", package: "Plot"), .product(name: "HTMLString", package: "HTMLString"), .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), diff --git a/Sources/TranslationCatalogIO/ExpressionEncoder.swift b/Sources/TranslationCatalogIO/ExpressionEncoder.swift index 4c7ba87..d447c65 100644 --- a/Sources/TranslationCatalogIO/ExpressionEncoder.swift +++ b/Sources/TranslationCatalogIO/ExpressionEncoder.swift @@ -1,5 +1,4 @@ import Foundation -import Plot import TranslationCatalog /// Utility for encoding catalog `Translation`s for export/transfer. @@ -23,14 +22,19 @@ public struct ExpressionEncoder { ) throws -> Data { switch format { case .androidXML: - let sorted = expressions.sorted(by: { $0.key < $1.key }) - let xml = XML.make( - with: sorted, - locale: locale, - fallback: fallback - ) - let raw = xml.render(indentedBy: .spaces(2)) - return raw.data(using: .utf8) ?? Data() + let sorted = expressions + .compactMap(locale: locale, fallback: fallback) + .sorted(by: { $0.key < $1.key }) + let resources = sorted.map { exp -> Resource in + let multipleReplacements = exp.defaultValue.hasMultipleReplacements + return Resource( + name: exp.key, + value: exp.valueOrDefault(for: locale), + formatted: multipleReplacements ? false : nil + ) + } + let strings = StringsXml(resources: resources) + return try strings.encoded() case .appleStrings: let sorted = expressions.sorted(by: { $0.key < $1.key }) var output: [String] = [] diff --git a/Sources/TranslationCatalogIO/ExpressionRenderer.swift b/Sources/TranslationCatalogIO/ExpressionRenderer.swift deleted file mode 100644 index bb2cf12..0000000 --- a/Sources/TranslationCatalogIO/ExpressionRenderer.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Plot -import TranslationCatalog - -/// Utility for rendering expressions in multiple formats. -public struct ExpressionRenderer { - - private init() {} - - /// Create a string representation of a rendered `Expression` document. - /// - /// - parameters: - /// - expressions: The collection of `Expression` which should be rendered into the document. - /// - renderFormat: The output format for the document. - /// - returns: String representation of the requested text document. - public static func render( - expressions: [Expression], - renderFormat: RenderFormat - ) throws -> String { - switch renderFormat { - case .html: - let html = HTML.make(with: expressions) - return html.render(indentedBy: .spaces(2)) - case .markdown: - var md: String = "# Strings" - - for expression in expressions { - let table = try MarkdownTable<[Translation]>( - paths: [\.id, \.locale.identifier, \.value], - headers: ["ID", "Locale Identifier", "Value"] - ) - - let translations = expression.translations.sorted(by: { $0.language.identifier < $1.language.identifier }) - for translation in translations { - table.addRow(translation, strong: translation.locale == expression.locale) - } - - md += """ - \n - ## \(expression.key) - Id: \(expression.id) - Value: \(expression.defaultValue) - Language: \(expression.defaultLanguageCode.identifier) - Name: \(expression.name) - Context: \(expression.context ?? "") - Feature: \(expression.feature ?? "") - - \(table) - """ - } - - return md - } - } -} diff --git a/Sources/TranslationCatalogIO/Extensions/Expression+IO.swift b/Sources/TranslationCatalogIO/Extensions/Expression+IO.swift index dcacbbf..0147097 100644 --- a/Sources/TranslationCatalogIO/Extensions/Expression+IO.swift +++ b/Sources/TranslationCatalogIO/Extensions/Expression+IO.swift @@ -1,5 +1,4 @@ import Foundation -import Plot import TranslationCatalog extension TranslationCatalog.Expression { diff --git a/Sources/TranslationCatalogIO/Extensions/Plot+IO.swift b/Sources/TranslationCatalogIO/Extensions/Plot+IO.swift deleted file mode 100644 index 92c030a..0000000 --- a/Sources/TranslationCatalogIO/Extensions/Plot+IO.swift +++ /dev/null @@ -1,110 +0,0 @@ -import HTMLString -import Plot -import TranslationCatalog - -extension HTML { - static func make(with expressions: [Expression]) -> Self { - HTML( - .head( - .title("Localization Strings"), - .style(""" - body { - font-family: -apple-system, Helvetica, sans-serif; - } - - h1 { - color: purple; - } - - h2 { - color: royalblue; - } - - table, th, td { - border-collapse: collapse; - border: 1px solid gray; - } - - th { - color: slategray; - } - """) - ), - .body( - .div( - .h1("Strings") - ), - .forEach(expressions) { - .localization($0) - } - ) - ) - } -} - -extension Node where Context == HTML.BodyContext { - static func localization(_ expression: Expression) -> Self { - let values = expression.translations.sorted(by: { $0.language.identifier < $1.language.identifier }) - - return .div( - .h2( - .text(expression.name) - ), - .p( - .text("ID: \(expression.id)"), - .br(), - .text("Key: \(expression.key)"), - .br(), - .text("Context: \(expression.context ?? "")"), - .br(), - .text("Feature: \(expression.feature ?? "")") - ), - .table( - .tr( - .th("ID"), - .th("Locale Identifier"), - .th("Value") - ), - .forEach(values) { - .if($0.language == expression.defaultLanguageCode, .defaultValue($0), else: .value($0)) - } - ) - ) - } -} - -extension Node where Context == HTML.TableContext { - static func value(_ translation: Translation) -> Self { - .tr( - .td( - .text("\(translation.id)") - ), - .td( - .text(translation.locale.identifier) - ), - .td( - .raw(translation.value.addingASCIIEntities()) - ) - ) - } - - static func defaultValue(_ translation: Translation) -> Self { - .tr( - .td( - .b( - .text("\(translation.id)") - ) - ), - .td( - .b( - .text(translation.locale.identifier) - ) - ), - .td( - .b( - .raw(translation.value.addingASCIIEntities()) - ) - ) - ) - } -} diff --git a/Sources/TranslationCatalogIO/Extensions/XML+Expression.swift b/Sources/TranslationCatalogIO/Extensions/XML+Expression.swift deleted file mode 100644 index f3afdbf..0000000 --- a/Sources/TranslationCatalogIO/Extensions/XML+Expression.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation -import Plot -import TranslationCatalog - -extension XML { - static func make( - with expressions: [TranslationCatalog.Expression], - locale: Locale, - fallback: Bool - ) -> Self { - let filtered = expressions.compactMap(locale: locale, fallback: fallback) - - return XML( - .element(named: "resources", nodes: [ - .forEach(filtered) { - .element(named: "string", nodes: [ - .attribute(named: "name", value: $0.key), - .attribute( - named: "formatted", - value: $0.valueOrDefault(for: locale).hasMultipleReplacements ? "false" : "", - ignoreIfValueIsEmpty: true - ), - .text($0.valueOrDefault(for: locale).simpleAndroidXMLEscaped()), - ]) - }, - ]) - ) - } -} diff --git a/Sources/TranslationCatalogIO/Internal/Resource.swift b/Sources/TranslationCatalogIO/Internal/Resource.swift index 84567d0..0be5baa 100644 --- a/Sources/TranslationCatalogIO/Internal/Resource.swift +++ b/Sources/TranslationCatalogIO/Internal/Resource.swift @@ -2,23 +2,32 @@ import Foundation import TranslationCatalog import XMLCoder -struct Resource: Decodable, DynamicNodeDecoding { +struct Resource: Codable, DynamicNodeDecoding, DynamicNodeEncoding { enum CodingKeys: String, CodingKey { case name + case formatted case value = "" } - var name: String - var value: String + let name: String + let value: String + let formatted: Bool? static func nodeDecoding(for key: CodingKey) -> XMLDecoder.NodeDecoding { switch key { - case CodingKeys.name: + case CodingKeys.name, CodingKeys.formatted: .attribute - case CodingKeys.value: + default: .element + } + } + + static func nodeEncoding(for key: any CodingKey) -> XMLEncoder.NodeEncoding { + switch key { + case CodingKeys.name, CodingKeys.formatted: + .attribute default: - .elementOrAttribute + .element } } } diff --git a/Sources/TranslationCatalogIO/Internal/StringsXml.swift b/Sources/TranslationCatalogIO/Internal/StringsXml.swift index b738b4b..779f050 100644 --- a/Sources/TranslationCatalogIO/Internal/StringsXml.swift +++ b/Sources/TranslationCatalogIO/Internal/StringsXml.swift @@ -2,16 +2,20 @@ import Foundation import TranslationCatalog import XMLCoder -struct StringsXml: Decodable, DynamicNodeDecoding { +struct StringsXml: Codable, DynamicNodeDecoding, DynamicNodeEncoding { enum CodingKeys: String, CodingKey { case resources = "string" } - var resources: [Resource] + let resources: [Resource] static func nodeDecoding(for key: CodingKey) -> XMLDecoder.NodeDecoding { .element } + + static func nodeEncoding(for key: any CodingKey) -> XMLEncoder.NodeEncoding { + .element + } static func make(contentsOf url: URL) throws -> StringsXml { let data = try Data(contentsOf: url) @@ -21,6 +25,24 @@ struct StringsXml: Decodable, DynamicNodeDecoding { static func make(with data: Data) throws -> StringsXml { try XMLDecoder().decode(StringsXml.self, from: data) } + + func encoded() throws -> Data { + let encoder = XMLEncoder() + encoder.outputFormatting = [.sortedKeys] + let encoded = try encoder.encode( + self, + withRootKey: "resources", + header: XMLHeader( + version: 1.0, + encoding: "UTF-8" + ) + ) + var string = String(decoding: encoded, as: UTF8.self) + string = string.replacingOccurrences(of: ">", with: ">\n") + string = string.replacingOccurrences(of: ">\n ", with: ">\n") + return string.data(using: .utf8) ?? encoded + } } extension StringsXml { diff --git a/Sources/TranslationCatalogIO/RenderFormat.swift b/Sources/TranslationCatalogIO/RenderFormat.swift index 586781e..509459d 100644 --- a/Sources/TranslationCatalogIO/RenderFormat.swift +++ b/Sources/TranslationCatalogIO/RenderFormat.swift @@ -1,4 +1,5 @@ /// File types which can be generated containing `Expression`/`Translation` info. +@available(*, deprecated) public enum RenderFormat: String, CaseIterable { case html case markdown diff --git a/Sources/localizer/Catalog+Generate.swift b/Sources/localizer/Catalog+Generate.swift deleted file mode 100644 index 710327b..0000000 --- a/Sources/localizer/Catalog+Generate.swift +++ /dev/null @@ -1,46 +0,0 @@ -import ArgumentParser -import Foundation -import TranslationCatalog -import TranslationCatalogIO - -extension Catalog { - struct Generate: CatalogCommand { - - static let configuration = CommandConfiguration( - commandName: "generate", - abstract: "Generate a viewable document using the strings catalog.", - discussion: """ - Available formats: \(RenderFormat.allCases.map(\.rawValue).joined(separator: " ")) - """, - version: "1.0.0", - helpNames: .shortAndLong - ) - - @Argument(help: "The export format") - var format: RenderFormat - - @Option(help: "Storage mechanism used to persist the catalog. (*default) [core-data, filesystem, *sqlite]") - var storage: Catalog.Storage = .default - - @Option(help: "Path to catalog to use in place of the application library.") - var path: String? - - @Flag(help: "Additional execution details in the standard output.") - var verbose: Bool = false - - func run() async throws { - let catalog = try catalog() - let expressions = try catalog.expressions() - let sortedExpressions = expressions.sorted(by: { $0.key < $1.key }) - let expressionsWithTranslations = try sortedExpressions.map { expression in - let translations = try catalog.translations(matching: GenericTranslationQuery.expressionId(expression.id)) - return TranslationCatalog.Expression( - expression: expression, - translations: translations - ) - } - let render = try ExpressionRenderer.render(expressions: expressionsWithTranslations, renderFormat: format) - print(render) - } - } -} diff --git a/Sources/localizer/Catalog+Syntax.swift b/Sources/localizer/Catalog+Syntax.swift index 59a8b93..8729f45 100644 --- a/Sources/localizer/Catalog+Syntax.swift +++ b/Sources/localizer/Catalog+Syntax.swift @@ -11,7 +11,7 @@ extension Catalog { commandName: "syntax", abstract: "Create a enumerated syntax tree.", discussion: """ - Generate a enumerate reference to strings. For example: + Generate an enumerated reference to strings. For example: enum LocalizedStrings: String, LocalizedStringConvertible { /// Title for account screen. diff --git a/Sources/localizer/Catalog.swift b/Sources/localizer/Catalog.swift index 7be125c..61ced84 100644 --- a/Sources/localizer/Catalog.swift +++ b/Sources/localizer/Catalog.swift @@ -24,7 +24,6 @@ struct Catalog: AsyncParsableCommand { subcommands: [ Export.self, Delete.self, - Generate.self, Import.self, Insert.self, Query.self, diff --git a/Sources/localizer/Extensions/RenderFormat+localizer.swift b/Sources/localizer/Extensions/RenderFormat+localizer.swift index ffcb841..6a70a74 100644 --- a/Sources/localizer/Extensions/RenderFormat+localizer.swift +++ b/Sources/localizer/Extensions/RenderFormat+localizer.swift @@ -1,4 +1,5 @@ import ArgumentParser import TranslationCatalogIO +@available(*, deprecated) extension RenderFormat: ExpressibleByArgument {} diff --git a/Tests/LocalizerTests/CatalogGenerateTests.swift b/Tests/LocalizerTests/CatalogGenerateTests.swift deleted file mode 100644 index 5d88bf7..0000000 --- a/Tests/LocalizerTests/CatalogGenerateTests.swift +++ /dev/null @@ -1,18 +0,0 @@ -import XCTest - -final class CatalogGenerateTests: XCTestCase { - - func testExecute() throws { - let process = LocalizerProcess() - let output = try process.runOutputting(with: [ - "catalog", "generate", "markdown", "--path", process.url.lastPathComponent, - ]) - - XCTAssertEqual(output, """ - # Strings - - """) - - try process.recycle() - } -} diff --git a/Tests/LocalizerTests/CoreDataExportTests.swift b/Tests/LocalizerTests/CoreDataExportTests.swift index 0ca32b6..f1bb72c 100644 --- a/Tests/LocalizerTests/CoreDataExportTests.swift +++ b/Tests/LocalizerTests/CoreDataExportTests.swift @@ -48,15 +48,14 @@ final class CoreDataExportTests: LocalizerTestCase { XCTAssertEqual(output, """ - - - Lingua - Hello World! - - Android - Apple - Web - + + Lingua + Hello World! + + Android + Apple + Web + """) } @@ -68,11 +67,10 @@ final class CoreDataExportTests: LocalizerTestCase { XCTAssertEqual(output, """ - - - Hola Mundo! - solo en español - + + Hola Mundo! + solo en español + """) } @@ -84,15 +82,14 @@ final class CoreDataExportTests: LocalizerTestCase { XCTAssertEqual(output, """ - - - Lingua - Hola Mundo! - solo en español - Android - Apple - Web - + + Lingua + Hola Mundo! + solo en español + Android + Apple + Web + """) } diff --git a/Tests/LocalizerTests/FilesystemExportTests.swift b/Tests/LocalizerTests/FilesystemExportTests.swift index 7be7baf..1dfec73 100644 --- a/Tests/LocalizerTests/FilesystemExportTests.swift +++ b/Tests/LocalizerTests/FilesystemExportTests.swift @@ -49,15 +49,14 @@ final class FilesystemExportTests: LocalizerTestCase { XCTAssertEqual(output, """ - - - Lingua - Hello World! - - Android - Apple - Web - + + Lingua + Hello World! + + Android + Apple + Web + """) } @@ -69,11 +68,10 @@ final class FilesystemExportTests: LocalizerTestCase { XCTAssertEqual(output, """ - - - Hola Mundo! - solo en español - + + Hola Mundo! + solo en español + """) } @@ -85,15 +83,14 @@ final class FilesystemExportTests: LocalizerTestCase { XCTAssertEqual(output, """ - - - Lingua - Hola Mundo! - solo en español - Android - Apple - Web - + + Lingua + Hola Mundo! + solo en español + Android + Apple + Web + """) } diff --git a/Tests/LocalizerTests/SQLiteExportTests.swift b/Tests/LocalizerTests/SQLiteExportTests.swift index 7f17f46..48bd9d4 100644 --- a/Tests/LocalizerTests/SQLiteExportTests.swift +++ b/Tests/LocalizerTests/SQLiteExportTests.swift @@ -47,15 +47,14 @@ final class SQLiteExportTests: LocalizerTestCase { XCTAssertEqual(output, """ - - - Lingua - Hello World! - - Android - Apple - Web - + + Lingua + Hello World! + + Android + Apple + Web + """) } @@ -67,11 +66,10 @@ final class SQLiteExportTests: LocalizerTestCase { XCTAssertEqual(output, """ - - - Hola Mundo! - solo en español - + + Hola Mundo! + solo en español + """) } @@ -83,15 +81,14 @@ final class SQLiteExportTests: LocalizerTestCase { XCTAssertEqual(output, """ - - - Lingua - Hola Mundo! - solo en español - Android - Apple - Web - + + Lingua + Hola Mundo! + solo en español + Android + Apple + Web + """) } diff --git a/Tests/TranslationCatalogTests/ExpressionEncoderTests.swift b/Tests/TranslationCatalogTests/ExpressionEncoderTests.swift new file mode 100644 index 0000000..b399369 --- /dev/null +++ b/Tests/TranslationCatalogTests/ExpressionEncoderTests.swift @@ -0,0 +1,78 @@ +import TranslationCatalog +@testable import TranslationCatalogIO +import XCTest + +final class ExpressionEncoderTests: XCTestCase { + + private let locale: Locale = Locale(identifier: "en_US") + private let expressions: [TranslationCatalog.Expression] = [ + Expression( + id: UUID(uuidString: "4D08CE7F-ED98-437E-B7CB-BB18147B9B1A")!, + key: "EXP_01", + value: "Hello World!", + languageCode: .english + ), + Expression( + id: UUID(uuidString: "5638B19C-2034-49F2-A163-54CC37269050")!, + key: "EXP_02", + value: "Hello %s!", + languageCode: .english + ), + Expression( + id: UUID(uuidString: "5870E853-35B9-4C71-B8D6-7EA922C72835")!, + key: "EXP_03", + value: "Hello %s, welcome to %s!", + languageCode: .english + ), + ] + + func testAndroidXMLEncoding() throws { + let data = try ExpressionEncoder.encodeValues( + for: expressions, + locale: locale, + fallback: true, + format: .androidXML + ) + let output = String(decoding: data, as: UTF8.self) + XCTAssertEqual(output, """ + + + Hello World! + Hello %s! + Hello %s, welcome to %s! + + """) + } + + func testAppleStringsEncoding() throws { + let data = try ExpressionEncoder.encodeValues( + for: expressions, + locale: locale, + fallback: true, + format: .appleStrings + ) + let output = String(decoding: data, as: UTF8.self) + XCTAssertEqual(output, """ + "EXP_01" = "Hello World!"; + "EXP_02" = "Hello %@!"; + "EXP_03" = "Hello %@, welcome to %@!"; + """) + } + + func testJSONEncoding() throws { + let data = try ExpressionEncoder.encodeValues( + for: expressions, + locale: locale, + fallback: true, + format: .json + ) + let output = String(decoding: data, as: UTF8.self) + XCTAssertEqual(output, """ + { + "EXP_01" : "Hello World!", + "EXP_02" : "Hello %s!", + "EXP_03" : "Hello %s, welcome to %s!" + } + """) + } +}