diff --git a/Packages/vChewing_MainAssembly/Sources/MainAssembly/CandidateTextService_SelectorImpl.swift b/Packages/vChewing_MainAssembly/Sources/MainAssembly/CandidateTextService_SelectorImpl.swift
new file mode 100644
index 00000000..4b420bbe
--- /dev/null
+++ b/Packages/vChewing_MainAssembly/Sources/MainAssembly/CandidateTextService_SelectorImpl.swift
@@ -0,0 +1,148 @@
+// (c) 2021 and onwards The vChewing Project (MIT-NTL License).
+// ====================
+// This code is released under the MIT license (SPDX-License-Identifier: MIT)
+// ... with NTL restriction stating that:
+// No trademark license is granted to use the trade names, trademarks, service
+// marks, or product names of Contributor, except as required to fulfill notice
+// requirements defined in MIT License.
+
+import BrailleSputnik
+import Foundation
+import Shared
+import Tekkon
+
+public extension CandidateTextService {
+ // MARK: - Selector Methods, CandidatePairServicable, and the Coordinator.
+
+ var responseFromSelector: String? {
+ switch value {
+ case .url: return nil
+ case let .selector(string):
+ let passable = CandidatePairServicable(value: candidateText, reading: reading)
+ return Coordinator().runTask(selectorName: string, candidate: passable)
+ }
+ }
+
+ @objcMembers class CandidatePairServicable: NSObject {
+ public var value: String
+ public var reading: [String]
+ public init(value: String, reading: [String] = []) {
+ self.value = value
+ self.reading = reading
+ }
+
+ public typealias SubPair = (key: String, value: String)
+
+ @nonobjc var smashed: [SubPair] {
+ var pairs = [SubPair]()
+ if value.count != reading.count {
+ pairs.append((reading.joined(separator: " "), value))
+ } else {
+ value.enumerated().forEach { i, valChar in
+ pairs.append((reading[i], valChar.description))
+ }
+ }
+ return pairs
+ }
+ }
+
+ @objc class Coordinator: NSObject {
+ private var result: String?
+
+ public func runTask(selectorName: String, candidate param: CandidatePairServicable) -> String? {
+ guard !selectorName.isEmpty, !param.value.isEmpty else { return nil }
+ guard responds(to: Selector(selectorName)) else { return nil }
+ performSelector(onMainThread: Selector(selectorName), with: param, waitUntilDone: true)
+ defer { result = nil }
+ return result
+ }
+
+ /// 生成 Unicode 統一碼碼位中繼資料。
+ /// - Parameter param: 要處理的詞音配對物件。
+ @objc func copyUnicodeMetadata(_ param: CandidatePairServicable) {
+ var resultArray = [String]()
+ param.value.forEach { char in
+ resultArray.append("\(char) \(char.description.charDescriptions.first ?? "NULL")")
+ }
+ result = resultArray.joined(separator: "\n")
+ }
+
+ /// 生成 HTML Ruby (教科書注音)。
+ /// - Parameter param: 要處理的詞音配對物件。
+ @objc func copyRubyHTMLZhuyinTextbookStyle(_ param: CandidatePairServicable) {
+ prepareTextBookZhuyinReadings(param)
+ copyRubyHTMLCommon(param)
+ }
+
+ /// 生成 HTML Ruby (教科書漢語拼音注音)。
+ /// - Parameter param: 要處理的詞音配對物件。
+ @objc func copyRubyHTMLHanyuPinyinTextbookStyle(_ param: CandidatePairServicable) {
+ prepareTextBookPinyinReadings(param)
+ copyRubyHTMLCommon(param)
+ }
+
+ /// 生成內文讀音標注 (教科書注音)。
+ /// - Parameter param: 要處理的詞音配對物件。
+ @objc func copyInlineZhuyinAnnotationTextbookStyle(_ param: CandidatePairServicable) {
+ prepareTextBookZhuyinReadings(param)
+ copyInlineAnnotationCommon(param)
+ }
+
+ /// 生成內文讀音標注 (教科書漢語拼音注音)。
+ /// - Parameter param: 要處理的詞音配對物件。
+ @objc func copyInlineHanyuPinyinAnnotationTextbookStyle(_ param: CandidatePairServicable) {
+ prepareTextBookPinyinReadings(param)
+ copyInlineAnnotationCommon(param)
+ }
+
+ @objc func copyBraille1947(_ param: CandidatePairServicable) {
+ result = BrailleSputnik(standard: .of1947).convertToBraille(smashedPairs: param.smashed)
+ }
+
+ @objc func copyBraille2018(_ param: CandidatePairServicable) {
+ result = BrailleSputnik(standard: .of2018).convertToBraille(smashedPairs: param.smashed)
+ }
+
+ // MARK: Privates
+ }
+}
+
+private extension CandidateTextService.Coordinator {
+ func copyInlineAnnotationCommon(_ param: CandidateTextService.CandidatePairServicable) {
+ var composed = ""
+ param.smashed.forEach { subPair in
+ let subKey = subPair.key
+ let subValue = subPair.value
+ composed += subKey.contains("_") ? subValue : "\(subValue)(\(subKey))"
+ }
+ result = composed
+ }
+
+ func copyRubyHTMLCommon(_ param: CandidateTextService.CandidatePairServicable) {
+ var composed = ""
+ param.smashed.forEach { subPair in
+ let subKey = subPair.key
+ let subValue = subPair.value
+ composed += subKey.contains("_") ? subValue : "\(subValue)"
+ }
+ result = composed
+ }
+
+ func prepareTextBookZhuyinReadings(_ param: CandidateTextService.CandidatePairServicable) {
+ let newReadings = param.reading.map { currentReading in
+ if currentReading.contains("_") { return "_??" }
+ return Tekkon.cnvPhonaToTextbookReading(target: currentReading)
+ }
+ param.reading = newReadings
+ }
+
+ func prepareTextBookPinyinReadings(_ param: CandidateTextService.CandidatePairServicable) {
+ let newReadings = param.reading.map { currentReading in
+ if currentReading.contains("_") { return "_??" }
+ return Tekkon.cnvHanyuPinyinToTextbookStyle(
+ targetJoined: Tekkon.cnvPhonaToHanyuPinyin(targetJoined: currentReading)
+ )
+ }
+ param.reading = newReadings
+ }
+}
diff --git a/Packages/vChewing_MainAssembly/Tests/MainAssemblyTests/CandidateServiceCoordinatorTests.swift b/Packages/vChewing_MainAssembly/Tests/MainAssemblyTests/CandidateServiceCoordinatorTests.swift
new file mode 100644
index 00000000..cf639e13
--- /dev/null
+++ b/Packages/vChewing_MainAssembly/Tests/MainAssemblyTests/CandidateServiceCoordinatorTests.swift
@@ -0,0 +1,126 @@
+// (c) 2021 and onwards The vChewing Project (MIT-NTL License).
+// ====================
+// This code is released under the MIT license (SPDX-License-Identifier: MIT)
+// ... with NTL restriction stating that:
+// No trademark license is granted to use the trade names, trademarks, service
+// marks, or product names of Contributor, except as required to fulfill notice
+// requirements defined in MIT License.
+
+import CocoaExtension
+import InputMethodKit
+import LangModelAssembly
+@testable import MainAssembly
+import Shared
+import XCTest
+
+class CandidateServiceCoordinatorTests: XCTestCase {
+ static let testDataMap: [String] = [
+ #"Unicode Metadata: %s"# + "\t" + #"@SEL:copyUnicodeMetadata:"#,
+ #"HTML Ruby Zhuyin: %s"# + "\t" + #"@SEL:copyRubyHTMLZhuyinTextbookStyle:"#,
+ #"HTML Ruby Pinyin: %s"# + "\t" + #"@SEL:copyRubyHTMLHanyuPinyinTextbookStyle:"#,
+ #"Zhuyin Annotation: %s"# + "\t" + #"@SEL:copyInlineZhuyinAnnotationTextbookStyle:"#,
+ #"Pinyin Annotation: %s"# + "\t" + #"@SEL:copyInlineHanyuPinyinAnnotationTextbookStyle:"#,
+ #"Braille 1947: %s"# + "\t" + #"@SEL:copyBraille1947:"#,
+ #"Braille 2018: %s"# + "\t" + #"@SEL:copyBraille2018:"#,
+ ]
+
+ func testSelector_UnicodeMetadata() throws {
+ let stacked = Self.testDataMap.parseIntoCandidateTextServiceStack(
+ candidate: "胡桃", reading: ["ㄏㄨˊ", "ㄊㄠˊ"]
+ )
+ let theService = stacked[0]
+ switch theService.value {
+ case .url: break
+ case .selector:
+ let response = theService.responseFromSelector
+ let expectedResponse = "胡 U+80E1 CJK UNIFIED IDEOGRAPH-80E1\n桃 U+6843 CJK UNIFIED IDEOGRAPH-6843"
+ XCTAssertEqual(response, expectedResponse)
+ }
+ }
+
+ func testSelector_HTMLRubyZhuyinTextbookStyle() throws {
+ let stacked = Self.testDataMap.parseIntoCandidateTextServiceStack(
+ candidate: "甜的", reading: ["ㄊㄧㄢˊ", "ㄉㄜ˙"]
+ )
+ let theService = stacked[1]
+ switch theService.value {
+ case .url: break
+ case .selector:
+ let response = theService.responseFromSelector
+ let expectedResponse = "甜的"
+ XCTAssertEqual(response, expectedResponse)
+ }
+ }
+
+ func testSelector_HTMLRubyPinyinTextbookStyle() throws {
+ let stacked = Self.testDataMap.parseIntoCandidateTextServiceStack(
+ candidate: "鐵嘴", reading: ["ㄊㄧㄝˇ", "ㄗㄨㄟˇ"]
+ )
+ let theService = stacked[2]
+ switch theService.value {
+ case .url: break
+ case .selector:
+ let response = theService.responseFromSelector
+ let expectedResponse = "鐵嘴"
+ XCTAssertEqual(response, expectedResponse)
+ }
+ }
+
+ func testSelector_InlineAnnotationZhuyinTextbookStyle() throws {
+ let stacked = Self.testDataMap.parseIntoCandidateTextServiceStack(
+ candidate: "甜的", reading: ["ㄊㄧㄢˊ", "ㄉㄜ˙"]
+ )
+ let theService = stacked[3]
+ switch theService.value {
+ case .url: break
+ case .selector:
+ let response = theService.responseFromSelector
+ let expectedResponse = "甜(ㄊㄧㄢˊ)的(˙ㄉㄜ)"
+ XCTAssertEqual(response, expectedResponse)
+ }
+ }
+
+ func testSelector_InlineAnnotationTextbookStyle() throws {
+ let stacked = Self.testDataMap.parseIntoCandidateTextServiceStack(
+ candidate: "鐵嘴", reading: ["ㄊㄧㄝˇ", "ㄗㄨㄟˇ"]
+ )
+ let theService = stacked[4]
+ switch theService.value {
+ case .url: break
+ case .selector:
+ let response = theService.responseFromSelector
+ let expectedResponse = "鐵(tiě)嘴(zuǐ)"
+ XCTAssertEqual(response, expectedResponse)
+ }
+ }
+
+ func testSelector_Braille1947() throws {
+ let stacked = Self.testDataMap.parseIntoCandidateTextServiceStack(
+ candidate: "高科技公司的",
+ reading: ["ㄍㄠ", "ㄎㄜ", "ㄐㄧˋ", "ㄍㄨㄥ", "ㄙ", "ㄉㄜ˙"]
+ )
+ let theService = stacked[5]
+ switch theService.value {
+ case .url: break
+ case .selector:
+ let response = theService.responseFromSelector
+ let expectedResponse = "⠅⠩⠄⠇⠮⠄⠅⠡⠐⠅⠯⠄⠑⠄⠙⠮⠁"
+ XCTAssertEqual(response, expectedResponse)
+ }
+ }
+
+ func testSelector_Braille2018() throws {
+ let stacked = Self.testDataMap.parseIntoCandidateTextServiceStack(
+ candidate: "高科技公司的",
+ reading: ["ㄍㄠ", "ㄎㄜ", "ㄐㄧˋ", "ㄍㄨㄥ", "ㄙ", "ㄉㄜ˙"]
+ )
+ let theService = stacked[6]
+ switch theService.value {
+ case .url: break
+ case .selector:
+ let response = theService.responseFromSelector
+ let expectedResponse = "⠛⠖⠁⠅⠢⠁⠛⠊⠆⠛⠲⠁⠎⠁⠙⠢"
+ XCTAssertEqual(response, expectedResponse)
+ }
+ }
+}
diff --git a/Packages/vChewing_Shared/Sources/Shared/CandidateTextService.swift b/Packages/vChewing_Shared/Sources/Shared/CandidateTextService.swift
new file mode 100644
index 00000000..b11f693a
--- /dev/null
+++ b/Packages/vChewing_Shared/Sources/Shared/CandidateTextService.swift
@@ -0,0 +1,102 @@
+// (c) 2021 and onwards The vChewing Project (MIT-NTL License).
+// ====================
+// This code is released under the MIT license (SPDX-License-Identifier: MIT)
+// ... with NTL restriction stating that:
+// No trademark license is granted to use the trade names, trademarks, service
+// marks, or product names of Contributor, except as required to fulfill notice
+// requirements defined in MIT License.
+
+import Foundation
+
+public struct CandidateTextService: Codable {
+ public enum ServiceValueType: Int {
+ case url = 0
+ case selector = 1
+ }
+
+ public enum ServiceValue: Codable {
+ case url(URL)
+ case selector(String)
+ }
+
+ public let key: String
+ public let reading: [String]
+ public let menuTitle: String
+ public let definedValue: String
+ public let value: ServiceValue
+ public let candidateText: String
+
+ public init?(key: String, definedValue: String, param: String = #"%s"#, reading: [String] = []) {
+ guard !key.isEmpty, !definedValue.isEmpty, definedValue.first != "#" else { return nil }
+ candidateText = param
+ self.key = key.replacingOccurrences(of: #"%s"#, with: param)
+ self.reading = reading
+ let rawKeyHasParam = self.key != key
+ self.definedValue = definedValue.replacingOccurrences(of: #"%s"#, with: param)
+
+ // Handle Symbol Menu Title
+ var newMenuTitle = self.key
+ if param.count == 1, let strUTFCharCode = param.first?.codePoint, rawKeyHasParam {
+ newMenuTitle = "\(self.key) (\(strUTFCharCode))"
+ }
+ menuTitle = newMenuTitle
+
+ // Start parsing rawValue
+ var temporaryRawValue = definedValue
+ var finalServiceValue: ServiceValue?
+ let fetchedTypeHeader = temporaryRawValue.prefix(5)
+ guard fetchedTypeHeader.count == 5 else { return nil }
+ for _ in 0 ..< 5 {
+ temporaryRawValue.removeFirst()
+ }
+ switch fetchedTypeHeader.uppercased() {
+ case #"@SEL:"#:
+ finalServiceValue = .selector(temporaryRawValue)
+ case #"@WEB:"#, #"@URL:"#:
+ let encodedParam = param.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed)
+ guard let encodedParam = encodedParam else { return nil }
+ let newURL = URL(string: temporaryRawValue.replacingOccurrences(of: #"%s"#, with: encodedParam))
+ guard let newURL = newURL else { return nil }
+ finalServiceValue = .url(newURL)
+ default: return nil
+ }
+ guard let finalServiceValue = finalServiceValue else { return nil }
+ value = finalServiceValue
+ }
+}
+
+extension CandidateTextService: RawRepresentable {
+ public init?(rawValue: String) {
+ let cells = rawValue.components(separatedBy: "\t")
+ guard cells.count == 2 else { return nil }
+ self.init(key: cells[0], definedValue: cells[1])
+ }
+
+ public var rawValue: String {
+ "\(key)\t\(definedValue)"
+ }
+
+ public init?(rawValue: String, param: String, reading: [String]) {
+ let cells = rawValue.components(separatedBy: "\t")
+ guard cells.count >= 2 else { return nil }
+ self.init(key: cells[0], definedValue: cells[1], param: param, reading: reading)
+ }
+}
+
+// MARK: - Extensions
+
+public extension Array where Element == CandidateTextService {
+ var rawRepresentation: [String] {
+ map(\.rawValue)
+ }
+}
+
+public extension Array where Element == String {
+ func parseIntoCandidateTextServiceStack(
+ candidate: String = #"%s"#, reading: [String] = []
+ ) -> [CandidateTextService] {
+ compactMap { rawValue in
+ CandidateTextService(rawValue: rawValue, param: candidate, reading: reading)
+ }
+ }
+}
diff --git a/Packages/vChewing_Shared/Tests/SharedTests/SharedTests.swift b/Packages/vChewing_Shared/Tests/SharedTests/SharedTests.swift
index 28ea972a..0c0c1018 100644
--- a/Packages/vChewing_Shared/Tests/SharedTests/SharedTests.swift
+++ b/Packages/vChewing_Shared/Tests/SharedTests/SharedTests.swift
@@ -10,9 +10,26 @@
import XCTest
final class SharedTests: XCTestCase {
+ // MARK: - PrefMgr().dumpShellScriptBackup()
+
func testDumpedPrefs() throws {
let prefs = PrefMgr()
let fetched = prefs.dumpShellScriptBackup() ?? ""
XCTAssertFalse(fetched.isEmpty)
}
+
+ // MARK: - CandidateTextService (Basic Tests)
+
+ static let testDataMap: [String] = [
+ #"Bing: %s"# + "\t" + #"@WEB:https://www.bing.com/search?q=%s"#,
+ #"Ecosia: %s"# + "\t" + #"@WEB:https://www.ecosia.org/search?method=index&q=%s"#,
+ ]
+
+ func testDataRestoration() throws {
+ let stacked = Self.testDataMap.parseIntoCandidateTextServiceStack()
+ stacked.forEach { currentService in
+ print(currentService)
+ }
+ XCTAssertEqual(stacked.rawRepresentation, Self.testDataMap)
+ }
}