Skip to content

Commit

Permalink
Added an option to specify the language pair for the translation (#10)
Browse files Browse the repository at this point in the history
  • Loading branch information
Yurii Samsoniuk committed Jan 28, 2021
1 parent 61ea6d3 commit 207e05e
Show file tree
Hide file tree
Showing 16 changed files with 567 additions and 88 deletions.
16 changes: 12 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
)
14 changes: 14 additions & 0 deletions Sources/Common/URLLoader.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
15 changes: 15 additions & 0 deletions Sources/Linguee/Language.swift
Original file line number Diff line number Diff line change
@@ -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")
}
77 changes: 47 additions & 30 deletions Sources/Linguee/Linguee.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Combine
import Common
import Foundation
import SwiftSoup

Expand All @@ -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? {
Expand All @@ -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),
Expand All @@ -41,43 +49,52 @@ public class Linguee {
case generic(Swift.Error)
}

private let languagePair: LanguagePair
private let loader: URLLoader
private var cancellables = Set<AnyCancellable>()

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)
}
}

Expand Down
16 changes: 15 additions & 1 deletion Sources/LingueeOnAlfred/LingueeSearchWorkflow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:))
Expand All @@ -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)
}

Expand Down
13 changes: 1 addition & 12 deletions Sources/Updater/GitHubAPI.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Combine
import Common
import Foundation

public struct Asset {
Expand Down Expand Up @@ -61,18 +62,6 @@ public protocol GitHubAPI {
func getLatestRelease(user: String, repository: String) -> Future<LatestRelease, GitHubAPIError>
}

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<AnyCancellable> = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import Combine
import Foundation
import XCTest

class TestSubscriber<Input, Failure: Error>: Subscriber {
var receivedValues: [Input] = []
public class TestSubscriber<Input, Failure: Error>: Subscriber {
public var receivedValues: [Input] = []
private let completionSemaphore = DispatchSemaphore(value: 1)
private var completion: Subscribers.Completion<Failure>? = nil

init() {
public init() {
}

func waitForCompletion(_ timeout: DispatchTimeInterval = .milliseconds(100)) -> Subscribers
public func waitForCompletion(_ timeout: DispatchTimeInterval = .milliseconds(100)) -> Subscribers
.Completion<Failure>?
{
let waitResult = completionSemaphore.wait(timeout: .now() + timeout)
Expand All @@ -20,38 +20,38 @@ class TestSubscriber<Input, Failure: Error>: 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<Failure>) {
public func receive(completion: Subscribers.Completion<Failure>) {
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
}
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)
}
Expand Down
25 changes: 25 additions & 0 deletions Tests/CommonTesting/URLLoaderFake.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Combine
import Common
import Foundation

func notFaked1Fatal<T, R>(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()
}
}
51 changes: 51 additions & 0 deletions Tests/LingueeTests/LingueeTest.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Loading

0 comments on commit 207e05e

Please sign in to comment.