From 207e05ea5cc27abe96c891d41dc21c35b7e34378 Mon Sep 17 00:00:00 2001 From: Yurii Samsoniuk Date: Sun, 20 Sep 2020 10:41:43 +0200 Subject: [PATCH] Added an option to specify the language pair for the translation (#10) --- Package.swift | 16 +- Sources/Common/URLLoader.swift | 14 + Sources/Linguee/Language.swift | 15 + Sources/Linguee/Linguee.swift | 77 ++-- .../LingueeSearchWorkflow.swift | 16 +- Sources/Updater/GitHubAPI.swift | 13 +- .../TestSubscriber.swift | 22 +- Tests/CommonTesting/URLLoaderFake.swift | 25 ++ Tests/LingueeTests/LingueeTest.swift | 51 +++ .../bereich-translation-response.html | 338 ++++++++++++++++++ Tests/LingueeTests/TestData.swift | 27 ++ Tests/UpdaterTests/GitHubAPIImplTests.swift | 1 + .../LatestResponse+SampleData.swift | 6 +- Tests/UpdaterTests/URLLoaderFake.swift | 22 -- Tests/UpdaterTests/UpdateMonitorTests.swift | 1 + info.plist.tmpl | 11 +- 16 files changed, 567 insertions(+), 88 deletions(-) create mode 100644 Sources/Common/URLLoader.swift create mode 100644 Sources/Linguee/Language.swift rename Tests/{UpdaterTests => CommonTesting}/TestSubscriber.swift (59%) create mode 100644 Tests/CommonTesting/URLLoaderFake.swift create mode 100644 Tests/LingueeTests/LingueeTest.swift create mode 100644 Tests/LingueeTests/Resources/bereich-translation-response.html create mode 100644 Tests/LingueeTests/TestData.swift delete mode 100644 Tests/UpdaterTests/URLLoaderFake.swift diff --git a/Package.swift b/Package.swift index b0e007f..fc1fd03 100644 --- a/Package.swift +++ b/Package.swift @@ -33,20 +33,28 @@ let package = Package( .target( name: "Linguee", - dependencies: ["SwiftSoup"]), + dependencies: ["Common", "SwiftSoup"]), .testTarget( name: "LingueeTests", - dependencies: ["Linguee"]), + dependencies: ["Linguee", "CommonTesting"], + resources: [.copy("Resources/bereich-translation-response.html")]), .target( name: "Updater", dependencies: [ - .product(name: "Logging", package: "swift-log") + "Common", + .product(name: "Logging", package: "swift-log"), ]), .testTarget( name: "UpdaterTests", - dependencies: ["Updater"], + dependencies: ["Updater", "CommonTesting"], resources: [.copy("Resources/github-latest-release-0.4.0.json")] ), + + .target(name: "Common"), + .target( + name: "CommonTesting", + dependencies: ["Common"], + path: "Tests/CommonTesting"), ] ) diff --git a/Sources/Common/URLLoader.swift b/Sources/Common/URLLoader.swift new file mode 100644 index 0000000..3ff2fc4 --- /dev/null +++ b/Sources/Common/URLLoader.swift @@ -0,0 +1,14 @@ +import Combine +import Foundation + +public protocol URLLoader { + func requestData(for url: URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError> +} + +extension URLSession: URLLoader { + public func requestData(for url: URL) -> AnyPublisher< + (data: Data, response: URLResponse), URLError + > { + return self.dataTaskPublisher(for: url).eraseToAnyPublisher() + } +} diff --git a/Sources/Linguee/Language.swift b/Sources/Linguee/Language.swift new file mode 100644 index 0000000..365b353 --- /dev/null +++ b/Sources/Linguee/Language.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct LanguagePair { + public var source: String + public var destination: String + + public init(source: String, destination: String) { + self.source = source + self.destination = destination + } +} + +extension LanguagePair { + public static var englishGerman = LanguagePair(source: "english", destination: "german") +} diff --git a/Sources/Linguee/Linguee.swift b/Sources/Linguee/Linguee.swift index e5f0725..5d1f7c8 100644 --- a/Sources/Linguee/Linguee.swift +++ b/Sources/Linguee/Linguee.swift @@ -1,4 +1,5 @@ import Combine +import Common import Foundation import SwiftSoup @@ -9,11 +10,16 @@ enum LingueeQueryMode: String { case regular = "query" } +extension LanguagePair { + fileprivate var lingueePath: String { + return "\(source)-\(destination)" + } +} + extension URL { static let linguee = URL(string: "https://www.linguee.com")! - private static let searchPath = "/english-german/search" private static let sourceQueryItem = URLQueryItem(name: "source", value: "auto") static func linguee(_ href: String) -> URL? { @@ -23,9 +29,11 @@ extension URL { /// Returns a URL to search for `query` on Linguee. /// `mode` specifies if the query URL should point at a website version with stripped CSS, /// or to a regular full-blown version. - static func linqueeSearch(_ query: String, mode: LingueeQueryMode) -> URL { + static func linqueeSearch(_ query: String, mode: LingueeQueryMode, languagePair: LanguagePair) + -> URL + { var searchURL = URLComponents(url: URL.linguee, resolvingAgainstBaseURL: false)! - searchURL.path = self.searchPath + searchURL.path = "/\(languagePair.lingueePath)/search" searchURL.queryItems = [ self.sourceQueryItem, URLQueryItem(name: mode.rawValue, value: query), @@ -41,43 +49,52 @@ public class Linguee { case generic(Swift.Error) } + private let languagePair: LanguagePair + private let loader: URLLoader private var cancellables = Set() - public init() {} + public init(languagePair: LanguagePair = .englishGerman, loader: URLLoader = URLSession.shared) { + self.languagePair = languagePair + self.loader = loader + } /// Returns a Linguee URL pointing at the `query` results. - public static func searchURL(query: String) -> URL { - return .linqueeSearch(query, mode: .regular) + public static func searchURL(query: String, languagePair: LanguagePair = .englishGerman) + -> URL + { + return .linqueeSearch(query, mode: .regular, languagePair: languagePair) } public func search(for query: String) -> Future<[Autocompletion], Error> { return Future { (completion) in - URLSession.shared.dataTaskPublisher(for: .linqueeSearch(query, mode: .lightweight)) - .tryMap { (data, _) -> String in - // Linguee returns content in iso-8859-15 encoding. - guard let html = String(data: data, encoding: .isoLatin1) else { - throw Error.badEncoding - } - return html - } - .tryMap { html in - let document = try SwiftSoup.parse(html) - return try self.selectTranslations(in: document) + self.loader.requestData( + for: .linqueeSearch(query, mode: .lightweight, languagePair: self.languagePair) + ) + .tryMap { (data, _) -> String in + // Linguee returns content in iso-8859-15 encoding. + guard let html = String(data: data, encoding: .isoLatin1) else { + throw Error.badEncoding } - .sink( - receiveCompletion: { result in - switch result { - case .failure(let error): - completion(.failure(.generic(error))) - default: - return - } - }, - receiveValue: { results in - completion(.success(results)) + return html + } + .tryMap { html in + let document = try SwiftSoup.parse(html) + return try self.selectTranslations(in: document) + } + .sink( + receiveCompletion: { result in + switch result { + case .failure(let error): + completion(.failure(.generic(error))) + default: + return } - ) - .store(in: &self.cancellables) + }, + receiveValue: { results in + completion(.success(results)) + } + ) + .store(in: &self.cancellables) } } diff --git a/Sources/LingueeOnAlfred/LingueeSearchWorkflow.swift b/Sources/LingueeOnAlfred/LingueeSearchWorkflow.swift index e3f75d2..d49e638 100644 --- a/Sources/LingueeOnAlfred/LingueeSearchWorkflow.swift +++ b/Sources/LingueeOnAlfred/LingueeSearchWorkflow.swift @@ -23,6 +23,17 @@ extension WorkflowEnvironment { } } +// Language direction. +extension WorkflowEnvironment { + var sourceLanguage: String { + return environment["source_language", default: "english"] + } + + var destinationLanguage: String { + return environment["destination_language", default: "german"] + } +} + class LingueeSearchWorkflow { private static let logger = Logger( label: "\(LingueeSearchWorkflow.self)", factory: StreamLogHandler.standardError(label:)) @@ -35,7 +46,10 @@ class LingueeSearchWorkflow { init(query: String, environment: WorkflowEnvironment = .default) { self.query = query self.environment = environment - self.linguee = Linguee() + let languagePair = LanguagePair( + source: environment.sourceLanguage, + destination: environment.destinationLanguage) + self.linguee = Linguee(languagePair: languagePair) self.updateMonitor = LingueeSearchWorkflow.makeUpdateMonitor(environment: environment) } diff --git a/Sources/Updater/GitHubAPI.swift b/Sources/Updater/GitHubAPI.swift index dcce610..8b21eb4 100644 --- a/Sources/Updater/GitHubAPI.swift +++ b/Sources/Updater/GitHubAPI.swift @@ -1,4 +1,5 @@ import Combine +import Common import Foundation public struct Asset { @@ -61,18 +62,6 @@ public protocol GitHubAPI { func getLatestRelease(user: String, repository: String) -> Future } -public protocol URLLoader { - func requestData(for url: URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError> -} - -extension URLSession: URLLoader { - public func requestData(for url: URL) -> AnyPublisher< - (data: Data, response: URLResponse), URLError - > { - return self.dataTaskPublisher(for: url).eraseToAnyPublisher() - } -} - public class GitHubAPIImpl: GitHubAPI { private var cancellables: Set = [] diff --git a/Tests/UpdaterTests/TestSubscriber.swift b/Tests/CommonTesting/TestSubscriber.swift similarity index 59% rename from Tests/UpdaterTests/TestSubscriber.swift rename to Tests/CommonTesting/TestSubscriber.swift index cd9e222..968f629 100644 --- a/Tests/UpdaterTests/TestSubscriber.swift +++ b/Tests/CommonTesting/TestSubscriber.swift @@ -2,15 +2,15 @@ import Combine import Foundation import XCTest -class TestSubscriber: Subscriber { - var receivedValues: [Input] = [] +public class TestSubscriber: Subscriber { + public var receivedValues: [Input] = [] private let completionSemaphore = DispatchSemaphore(value: 1) private var completion: Subscribers.Completion? = nil - init() { + public init() { } - func waitForCompletion(_ timeout: DispatchTimeInterval = .milliseconds(100)) -> Subscribers + public func waitForCompletion(_ timeout: DispatchTimeInterval = .milliseconds(100)) -> Subscribers .Completion? { let waitResult = completionSemaphore.wait(timeout: .now() + timeout) @@ -20,30 +20,30 @@ class TestSubscriber: Subscriber { // MARK: - Subscriber - func receive(subscription: Subscription) { + public func receive(subscription: Subscription) { subscription.request(.unlimited) } - func receive(_ input: Input) -> Subscribers.Demand { + public func receive(_ input: Input) -> Subscribers.Demand { self.receivedValues.append(input) return .unlimited } - func receive(completion: Subscribers.Completion) { + public func receive(completion: Subscribers.Completion) { self.completion = completion completionSemaphore.signal() } } extension Subscribers.Completion { - func assertSuccess() { + public func assertSuccess() { if case .failure(let error) = self { XCTFail("Condition success not met. Failure with error: \(error)") } } - typealias ErrorAssertionBlock = (Failure) throws -> Void - func assertError(_ errorAssertion: ErrorAssertionBlock? = nil) rethrows { + public typealias ErrorAssertionBlock = (Failure) throws -> Void + public func assertError(_ errorAssertion: ErrorAssertionBlock? = nil) rethrows { guard case .failure(let error) = self else { XCTFail("Succeeded when expecting a failure.") return @@ -51,7 +51,7 @@ extension Subscribers.Completion { try errorAssertion?(error) } - func assertError(_ expectedError: Failure) where Failure: Equatable { + public func assertError(_ expectedError: Failure) where Failure: Equatable { self.assertError { (error) in XCTAssertEqual(error, expectedError) } diff --git a/Tests/CommonTesting/URLLoaderFake.swift b/Tests/CommonTesting/URLLoaderFake.swift new file mode 100644 index 0000000..d7d0562 --- /dev/null +++ b/Tests/CommonTesting/URLLoaderFake.swift @@ -0,0 +1,25 @@ +import Combine +import Common +import Foundation + +func notFaked1Fatal(file: StaticString = #file, line: UInt = #line) -> (T) -> R { + return { _ in fatalError("\(file):\(line) - Not faked!") } +} + +public class URLLoaderFake: URLLoader { + public class Stubs { + public var requestDataResult: (URL) -> Result<(data: Data, response: URLResponse), URLError> = + notFaked1Fatal() + } + public let stubs = Stubs() + + public init() {} + + public func requestData(for url: URL) -> AnyPublisher< + (data: Data, response: URLResponse), URLError + > { + Future { (completion) in + completion(self.stubs.requestDataResult(url)) + }.eraseToAnyPublisher() + } +} diff --git a/Tests/LingueeTests/LingueeTest.swift b/Tests/LingueeTests/LingueeTest.swift new file mode 100644 index 0000000..fdcc738 --- /dev/null +++ b/Tests/LingueeTests/LingueeTest.swift @@ -0,0 +1,51 @@ +import CommonTesting +import Foundation +import XCTest + +@testable import Linguee + +class LingueeTest: XCTestCase { + private var loader: URLLoaderFake! + private var linguee: Linguee! + private let translationSubscriber = TestSubscriber<[Autocompletion], Linguee.Error>() + + override func setUp() { + super.setUp() + loader = URLLoaderFake() + linguee = Linguee(loader: loader) + } + + /// Tests that a search is done against a valid search URL. + func testSearchURL() { + var requestURL: URL? + loader.stubs.requestDataResult = { url in + requestURL = url + return .success((Data(), URLResponse())) + } + + let _ = linguee.search(for: "hello").subscribe(translationSubscriber) + + translationSubscriber.waitForCompletion()?.assertSuccess() + XCTAssertEqual( + requestURL, URL(string: "https://www.linguee.com/english-german/search?source=auto&qe=hello")) + } + + /// Tests that a search is done for the provided language pair. + func testSearchBasedOnTheLanguagePair() { + var requestURL: URL? + loader.stubs.requestDataResult = { url in + requestURL = url + return .success((Data(), URLResponse())) + } + + let languagePair = LanguagePair(source: "italian", destination: "ukrainian") + linguee = Linguee(languagePair: languagePair, loader: loader) + let _ = linguee.search(for: "hello") + .subscribe(translationSubscriber) + + translationSubscriber.waitForCompletion()?.assertSuccess() + XCTAssertEqual( + requestURL, + URL(string: "https://www.linguee.com/italian-ukrainian/search?source=auto&qe=hello")) + } +} diff --git a/Tests/LingueeTests/Resources/bereich-translation-response.html b/Tests/LingueeTests/Resources/bereich-translation-response.html new file mode 100644 index 0000000..8494e8d --- /dev/null +++ b/Tests/LingueeTests/Resources/bereich-translation-response.html @@ -0,0 +1,338 @@ +
+
+
+
Bereich
m
+
+
+area +
n
+·field +
n
+·group +
n
+·sector +
n
+
+
+
+
Bereicherung
f
+
+
+enrichment +
n
+·gain +
n
+·enhancement +
n
+·asset +
n
+
+
+
+
bereichern
v
+
+
+enrich sth. +
v
+·enhance +
v
+
+
+
+
Bereichsleiter
pl
m
+
+
+division manager +
n
+·divisional head +
n
+·divisional director +
n
+·area manager +
n
+
+
+division managers +
pl
+·division heads +
pl
+·department heads +
pl
+·area managers +
pl
+
+
+
+
im Bereich von
+
+
+in the range of +
+
+
+
+
bereichernd
adj
+
+
+enriching +
adj
+·rewarding +
adj
+
+
+
+
bereichsübergreifend
adj
+
+
+cross-functional +
adj
+·trans-sectoral +
adj
+·cross-functional +
adj
+
+
+
+
Bereichsleitung
f
+
+
+divisional management +
n
+
+
+
+
bereichert
+
+
+enriched +
+
+
+
+
kaufmännischer Bereich
m
+
+
+commercial department +
n
+
+
+
+
bereichsweise
adv
+
+
+area by area +
adv
+
+
+
+
Bereichsvorstand
m
+
+
+divisional director +
n
+
+
+
+
verschiedene Bereiche
pl
+
+
+various areas +
pl
+·different areas +
pl
+·different fields +
pl
+·different sectors +
pl
+
+
+
+
unterer Bereich
m
+
+
+low area +
n
+
+
+
+
große Bereicherung
f
+
+
+big enrichment +
n
+·great asset +
n
+·huge asset +
n
+·major enrichment +
n
+
+
+
+
technischer Bereich
m
+
+
+technical sector +
n
+·technical field +
n
+·technical area +
n
+
+
+
+
einzelne Bereiche
pl
+
+
+individual areas +
pl
+·individual divisions +
pl
+·individual sectors +
pl
+·separate areas +
pl
+
+
+
+
oberer Bereich
m
+
+
+upper area +
n
+·upper range +
n
+
+
+
+
medizinischer Bereich
m
+
+
+medical field +
n
+·medical sector +
n
+·medical area +
n
+
+
+
+
im bereich
+
+
+
+
+
in diesem bereich
+
+
+
+
+
in den bereichen
+
+
+
+
+
aus dem bereich
+
+
+
+
+
in allen bereichen
+
+
+
+
+
im unteren bereich
+
+
+
+
+
aus den bereichen
+
+
+
+
+
im bereich des
+
+
+
+
+
im oberen bereich
+
+
+
+
+
bereicherungsvorsatz
+
+
+
+
+
in bereichen
+
+
+
+
+
bereichsarbeit
+
+
+
+
+
bereichbusiness to business
+
+
+
+
+
in den bereichen marketing
+
+
+
+
+
im business to business bereich
+
+
+
+
+
in bereichen in denen
+
+
+
+
+
it security bereich
+
+
+
+
+
in den bereichen personal
+
+
+
+
+
expertise in den bereichen
+
+
+
+
+
in allen it bereichen
+
+
+
+
+
in den bereichen design
+
+
+
+
+
know how in den bereichen
+
+
+
+
+
in bereiche von
+
+
+
+
+
im bereich research and development
+
+
+
\ No newline at end of file diff --git a/Tests/LingueeTests/TestData.swift b/Tests/LingueeTests/TestData.swift new file mode 100644 index 0000000..79d773b --- /dev/null +++ b/Tests/LingueeTests/TestData.swift @@ -0,0 +1,27 @@ +import Foundation +import Linguee + +extension MainItem { + static let bereich = MainItem( + phrase: "Bereich", wordTypes: ["m"], + link: URL(string: "https://www.linguee.com/german-english/translation/Bereich.html")!) +} + +extension Array where Element == TranslationItem { + static let bereich = [ + TranslationItem(translation: "area", wordTypes: ["n"]), + TranslationItem(translation: "field", wordTypes: ["n"]), + TranslationItem(translation: "group", wordTypes: ["n"]), + TranslationItem(translation: "sector", wordTypes: ["n"]), + ] +} + +extension Autocompletion { + static let bereich = Autocompletion( + mainItem: .bereich, translations: .bereich) + + static var bereichData: Data { + let url = Bundle.module.url(forResource: "bereich-translation-response", withExtension: "html")! + return try! Data(contentsOf: url) + } +} diff --git a/Tests/UpdaterTests/GitHubAPIImplTests.swift b/Tests/UpdaterTests/GitHubAPIImplTests.swift index 5463360..83f07e5 100644 --- a/Tests/UpdaterTests/GitHubAPIImplTests.swift +++ b/Tests/UpdaterTests/GitHubAPIImplTests.swift @@ -1,3 +1,4 @@ +import CommonTesting import XCTest @testable import Updater diff --git a/Tests/UpdaterTests/LatestResponse+SampleData.swift b/Tests/UpdaterTests/LatestResponse+SampleData.swift index 53036bc..73f2a00 100644 --- a/Tests/UpdaterTests/LatestResponse+SampleData.swift +++ b/Tests/UpdaterTests/LatestResponse+SampleData.swift @@ -5,11 +5,7 @@ import Foundation extension LatestRelease { /// A sample response stored in the `github-latest-release-0.4.0.json`. static var sampleData: Data { - // TODO(SR-12912): remove once the bug is fixed, and replace with: - // Bundle.module.url(forResource: "github-latest-release-0.4.0", withExtension: "json")! - let url = URL(fileURLWithPath: #file, isDirectory: false) - .deletingLastPathComponent() - .appendingPathComponent("Resources/github-latest-release-0.4.0.json") + let url = Bundle.module.url(forResource: "github-latest-release-0.4.0", withExtension: "json")! return try! Data(contentsOf: url) } diff --git a/Tests/UpdaterTests/URLLoaderFake.swift b/Tests/UpdaterTests/URLLoaderFake.swift deleted file mode 100644 index 4380e8b..0000000 --- a/Tests/UpdaterTests/URLLoaderFake.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Combine -import Foundation - -@testable import Updater - -func notFaked1Fatal(file: StaticString = #file, line: UInt = #line) -> (T) -> R { - return { _ in fatalError("\(file):\(line) - Not faked!") } -} - -class URLLoaderFake: URLLoader { - class Stubs { - var requestDataResult: (URL) -> Result<(data: Data, response: URLResponse), URLError> = - notFaked1Fatal() - } - let stubs = Stubs() - - func requestData(for url: URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError> { - Future { (completion) in - completion(self.stubs.requestDataResult(url)) - }.eraseToAnyPublisher() - } -} diff --git a/Tests/UpdaterTests/UpdateMonitorTests.swift b/Tests/UpdaterTests/UpdateMonitorTests.swift index 1cfe64c..6c6f77b 100644 --- a/Tests/UpdaterTests/UpdateMonitorTests.swift +++ b/Tests/UpdaterTests/UpdateMonitorTests.swift @@ -1,4 +1,5 @@ import Combine +import CommonTesting import Foundation import XCTest diff --git a/info.plist.tmpl b/info.plist.tmpl index f85cb97..fb7946a 100644 --- a/info.plist.tmpl +++ b/info.plist.tmpl @@ -103,8 +103,6 @@ 1 runningsubtext - script - ./LingueeOnAlfred "{query}" scriptargtype 1 scriptfile @@ -116,7 +114,7 @@ type 8 withspace - + type alfred.workflow.input.scriptfilter @@ -150,6 +148,9 @@ Use "l" to initiate the Linguee Search in the Alfred window. +Setting translation language: +There are two variables that indicate the language translation pair: source_language and destination_language. Default pair is English + German. To override this behavior, set the variables to a desired pair. For a full list of available language pairs please visit https://www.linguee.com/?moreLanguages=1#moreLanguages. The values of the variables must be a lowercased language name in English. E.g., "english", "german", "french". + Additionaly a system-wide shortcut can be added: 1. Double-click on the "Hotkey" box in the workflow (alternatively right click and choose "Configure Object..."). 2. Press the key combination that should be used as a system-wide shortcut. E.g., ⇧ ⌘ L (Shift-Command-L). @@ -183,6 +184,10 @@ Visit the workflow homepage to find out more about available features: https://t variables + source_language + english + destination_language + german check_for_updates true