Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FileDictをSwift Concurrency対応 #260

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions macSKK/Dict.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ protocol DictProtocol {
* - yomi: SKK辞書の見出し。複数のひらがな、もしくは複数のひらがな + ローマ字からなる文字列
* - option: 辞書を引くときに接頭辞、接尾辞や送り仮名ブロックから検索するかどうか。nilなら通常のエントリから検索する
*/
func refer(_ yomi: String, option: DictReferringOption?) -> [Word]
@MainActor func refer(_ yomi: String, option: DictReferringOption?) -> [Word]

/**
* 辞書にエントリを追加する。
Expand All @@ -48,7 +48,7 @@ protocol DictProtocol {
* - yomi: SKK辞書の見出し。複数のひらがな、もしくは複数のひらがな + ローマ字からなる文字列
* - word: SKK辞書の変換候補。
*/
mutating func add(yomi: String, word: Word)
@MainActor mutating func add(yomi: String, word: Word)

/**
* 辞書からエントリを削除する。
Expand All @@ -60,7 +60,7 @@ protocol DictProtocol {
* - word: SKK辞書の変換候補。
* - Returns: エントリを削除できたかどうか
*/
mutating func delete(yomi: String, word: Word.Word) -> Bool
@MainActor mutating func delete(yomi: String, word: Word.Word) -> Bool

/**
* 現在入力中のprefixに続く入力候補を1つ返す。見つからなければnilを返す。
Expand All @@ -73,5 +73,5 @@ protocol DictProtocol {
* - prefixと読みが完全に一致する場合は補完候補とはしない
* - 数値変換用の読みは補完候補としない
*/
func findCompletion(prefix: String) -> String?
@MainActor func findCompletion(prefix: String) -> String?
}
207 changes: 89 additions & 118 deletions macSKK/FileDict.swift

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion macSKK/MemoryDict.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import Foundation

/// 実ファイルをもたないSKK辞書
struct MemoryDict: DictProtocol {
struct MemoryDict: DictProtocol, Sendable {
/**
* 読み込み専用で保存しないかどうか
*
Expand Down
58 changes: 33 additions & 25 deletions macSKK/Settings/SettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -298,34 +298,40 @@ final class SettingsViewModel: ObservableObject {
Global.ignoreUserDictInPrivateMode.send(ignoreUserDictInPrivateMode)

// SKK-JISYO.Lのようなファイルの読み込みが遅いのでバックグラウンドで処理
$dictSettings.filter({ !$0.isEmpty }).receive(on: DispatchQueue.global()).sink { dictSettings in
let enabledDicts = dictSettings.compactMap { dictSetting -> FileDict? in
let dict = Global.dictionary.fileDict(id: dictSetting.id)
if dictSetting.enabled {
// 無効だった辞書が有効化された、もしくは辞書のエンコーディング設定が変わったら読み込む
if dictSetting.type.encoding != dict?.type.encoding {
let fileURL = dictionariesDirectoryUrl.appendingPathComponent(dictSetting.filename)
do {
logger.log("SKK辞書 \(dictSetting.filename, privacy: .public) を読み込みます")
let fileDict = try FileDict(contentsOf: fileURL, type: dictSetting.type, readonly: true)
logger.log("SKK辞書 \(dictSetting.filename, privacy: .public) から \(fileDict.entryCount) エントリ読み込みました")
return fileDict
} catch {
dictSetting.enabled = false
logger.log("SKK辞書 \(dictSetting.filename, privacy: .public) の読み込みに失敗しました!: \(error)")
return nil
$dictSettings.filter({ !$0.isEmpty }).receive(on: DispatchQueue.global()).flatMap { dictSettings in
Deferred {
var fileDicts: [FileDict] = []
return Future<[FileDict], Never>() { promise in
Task {
for dictSetting in dictSettings {
if !dictSetting.enabled {
continue
}
let dict = Global.dictionary.fileDict(id: dictSetting.id)
// 無効だった辞書が有効化された、もしくは辞書のエンコーディング設定が変わったら読み込む
if dictSetting.type.encoding != dict?.type.encoding {
let fileURL = dictionariesDirectoryUrl.appendingPathComponent(dictSetting.filename)
do {
logger.log("SKK辞書 \(dictSetting.filename, privacy: .public) を読み込みます")
let fileDict = FileDict(contentsOf: fileURL, type: dictSetting.type, readonly: true)
try await fileDict.load(fileURL: fileURL)
logger.log("SKK辞書 \(dictSetting.filename, privacy: .public) から \(fileDict.entryCount) エントリ読み込みました")
fileDicts.append(fileDict)
} catch {
dictSetting.enabled = false
logger.log("SKK辞書 \(dictSetting.filename, privacy: .public) の読み込みに失敗しました!: \(error)")
}
} else if let dict {
// 変更がないのでそのまま
fileDicts.append(dict)
}
}
} else {
return dict
}
} else {
if dict != nil {
logger.log("SKK辞書 \(dictSetting.filename, privacy: .public) を無効化します")
promise(.success(fileDicts))
}
return nil
}
}
Global.dictionary.dicts = enabledDicts
}.sink { fileDicts in
Global.dictionary.dicts = fileDicts
UserDefaults.standard.set(self.dictSettings.map { $0.encode() }, forKey: UserDefaultsKeys.dictionaries)
}
.store(in: &cancellables)
Expand Down Expand Up @@ -488,8 +494,10 @@ final class SettingsViewModel: ObservableObject {
if let userDict = Global.dictionary.userDict as? FileDict, userDict.id == loadEvent.id {
self.userDictLoadingStatus = loadEvent.status
if case .fail(let error) = loadEvent.status {
logger.error("辞書 \(loadEvent.id, privacy: .public) の読み込みでエラーが発生しました: \(error)")
UNNotifier.sendNotificationForUserDict(readError: error)
} else if case .loaded(_, let failureCount) = loadEvent.status, failureCount > 0 {
} else if case .loaded(let successCount, let failureCount) = loadEvent.status, failureCount > 0 {
logger.log("辞書 \(loadEvent.id, privacy: .public) から \(successCount) エントリ読み込みました")
UNNotifier.sendNotificationForUserDict(failureEntryCount: failureCount)
}
} else {
Expand Down
34 changes: 18 additions & 16 deletions macSKK/UserDict.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import Foundation
/// v0.22.0以降はskkservサーバーを辞書としても利用することが可能。
///
/// TODO: ファイル辞書にしかない単語を削除しようとしたときにどうやってそれを記録するか。NG登録?
class UserDict: NSObject, DictProtocol {
static let userDictFilename = "skk-jisyo.utf8"
@MainActor class UserDict: NSObject, DictProtocol {
nonisolated static let userDictFilename = "skk-jisyo.utf8"
let dictionariesDirectoryURL: URL
let userDictFileURL: URL
/**
Expand Down Expand Up @@ -54,12 +54,8 @@ class UserDict: NSObject, DictProtocol {
logger.log("ユーザー辞書ファイルがないため作成します")
try Data().write(to: userDictFileURL, options: .withoutOverwriting)
}
do {
let userDict = try FileDict(contentsOf: userDictFileURL, type: .traditional(.utf8), readonly: false)
self.userDict = userDict
} catch {
self.userDict = nil
}
let userDict = FileDict(contentsOf: userDictFileURL, type: .traditional(.utf8), readonly: false)
self.userDict = userDict
}
super.init()
NSFileCoordinator.addFilePresenter(self)
Expand All @@ -68,9 +64,9 @@ class UserDict: NSObject, DictProtocol {
// 短期間に複数の保存要求があっても60秒に一回にまとめる
.debounce(for: .seconds(60), scheduler: DispatchQueue.global(qos: .background))
.sink { [weak self] _ in
if let fileDict = self?.userDict as? FileDict {
if let self, let fileDict = self.userDict as? FileDict {
logger.log("ユーザー辞書を永続化します。現在のエントリ数は \(fileDict.dict.entries.count)")
fileDict.save()
fileDict.save(to: fileDict.fileURL, encoding: fileDict.type.encoding)
}
}
.store(in: &cancellables)
Expand All @@ -89,6 +85,12 @@ class UserDict: NSObject, DictProtocol {
NSFileCoordinator.removeFilePresenter(self)
}

func load() async throws {
if let userDict = userDict as? FileDict {
try await userDict.load(fileURL: userDictFileURL)
}
}

/**
* 保持する辞書を順に引き変換候補順に返す。
*
Expand All @@ -105,7 +107,7 @@ class UserDict: NSObject, DictProtocol {
* - yomi: SKK辞書の見出し。複数のひらがな、もしくは複数のひらがな + ローマ字からなる文字列
* - option: 辞書を引くときに接頭辞や接尾辞から検索するかどうか。nilなら通常のエントリから検索する
*/
@MainActor func referDicts(_ yomi: String, option: DictReferringOption? = nil) -> [Candidate] {
func referDicts(_ yomi: String, option: DictReferringOption? = nil) -> [Candidate] {
var result: [Candidate] = []
var candidates = refer(yomi, option: option).map { word in
let annotations: [Annotation] = if let annotation = word.annotation { [annotation] } else { [] }
Expand Down Expand Up @@ -255,7 +257,7 @@ class UserDict: NSObject, DictProtocol {
}
if let userDict {
if let dict = userDict as? FileDict {
dict.save()
dict.save(to: dict.fileURL, encoding: dict.type.encoding)
} else {
// ユニットテストなど特殊な場合のみ
logger.info("永続化が要求されましたが、ユーザー辞書がファイル形式でないため無視されます")
Expand All @@ -278,7 +280,7 @@ class UserDict: NSObject, DictProtocol {
}

extension UserDict: NSFilePresenter {
func presentedSubitemDidAppear(at url: URL) {
nonisolated func presentedSubitemDidAppear(at url: URL) {
do {
if try isValidFile(url) {
logger.log("新しいファイル \(url.lastPathComponent, privacy: .public) が作成されました")
Expand All @@ -293,7 +295,7 @@ extension UserDict: NSFilePresenter {
}

// 他フォルダから移動された場合だけでなく他フォルダに移動した場合にも発生する (後者はdidMoveToも発生する)
func presentedSubitemDidChange(at url: URL) {
nonisolated func presentedSubitemDidChange(at url: URL) {
// 削除されたときにaccommodatePresentedSubitemDeletionが呼ばれないがこのメソッドは呼ばれるようだった。
// そのためこのメソッドで削除のとき同様の処理を行う。
if !FileManager.default.fileExists(atPath: url.path) {
Expand Down Expand Up @@ -326,7 +328,7 @@ extension UserDict: NSFilePresenter {
}

// 子要素を他フォルダに移動した場合に発生する
func presentedSubitem(at oldURL: URL, didMoveTo newURL: URL) {
nonisolated func presentedSubitem(at oldURL: URL, didMoveTo newURL: URL) {
logger.log("ファイル \(oldURL.lastPathComponent, privacy: .public) が辞書フォルダから移動されました")
NotificationCenter.default.post(name: notificationNameDictFileDidMove, object: oldURL)
}
Expand All @@ -339,7 +341,7 @@ extension UserDict: NSFilePresenter {
}

// 辞書ファイルとして問題があるファイルでないかを判定する
private func isValidFile(_ fileURL: URL) throws -> Bool {
nonisolated private func isValidFile(_ fileURL: URL) throws -> Bool {
return try fileURL.isReadable()
}
}
3 changes: 3 additions & 0 deletions macSKK/macSKKApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@
setupDirectMode()
setupSettingsNotification()
}
Task {
try await Global.dictionary.load()
}
}

var body: some Scene {
Expand Down Expand Up @@ -299,7 +302,7 @@

private func setupSettingsNotification() {
Task {
for await notification in NotificationCenter.default.notifications(named: notificationNameOpenSettings) {

Check warning on line 305 in macSKK/macSKKApp.swift

View workflow job for this annotation

GitHub Actions / test

non-sendable type 'Notification?' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary; this is an error in the Swift 6 language mode
settingsWindowController.showWindow(notification.object)
NSApp.activate(ignoringOtherApps: true)
}
Expand Down
37 changes: 23 additions & 14 deletions macSKKTests/FileDictTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ final class FileDictTests: XCTestCase {
let fileURL = Bundle(for: FileDictTests.self).url(forResource: "empty", withExtension: "txt")!
var cancellables: Set<AnyCancellable> = []

func testLoadContainsBom() throws {
@MainActor func testLoadContainsBom() async throws {
let fileURL = Bundle(for: Self.self).url(forResource: "utf8-bom", withExtension: "txt")!
let dict = try FileDict(contentsOf: fileURL, type: .traditional(.utf8), readonly: true)
let dict = FileDict(contentsOf: fileURL, type: .traditional(.utf8), readonly: true)
try await dict.load(fileURL: fileURL)
XCTAssertEqual(dict.dict.entries, ["ゆにこーど": [Word("ユニコード")]])
}

func testLoadJson() throws {
@MainActor func testLoadJson() async throws {
let expectation = XCTestExpectation()
NotificationCenter.default.publisher(for: notificationNameDictLoad).sink { notification in
if let loadEvent = notification.object as? DictLoadEvent {
Expand All @@ -28,13 +29,13 @@ final class FileDictTests: XCTestCase {
}
}.store(in: &cancellables)
let fileURL = Bundle(for: Self.self).url(forResource: "SKK-JISYO.test", withExtension: "json")!
let dict = try FileDict(contentsOf: fileURL, type: .json, readonly: true)
let dict = FileDict(contentsOf: fileURL, type: .json, readonly: true)
try await dict.load(fileURL: fileURL)
XCTAssertEqual(dict.dict.refer("い", option: nil).map({ $0.word }).sorted(), ["伊", "胃"])
XCTAssertEqual(dict.dict.refer("あr", option: nil).map({ $0.word }).sorted(), ["在;注釈として解釈されない", "有"])
wait(for: [expectation], timeout: 1.0)
}

func testLoadJsonBroken() throws {
@MainActor func testLoadJsonBroken() async throws {
let expectation = XCTestExpectation()
NotificationCenter.default.publisher(for: notificationNameDictLoad).sink { notification in
if let loadEvent = notification.object as? DictLoadEvent {
Expand All @@ -44,12 +45,18 @@ final class FileDictTests: XCTestCase {
}
}.store(in: &cancellables)
let fileURL = Bundle(for: Self.self).url(forResource: "SKK-JISYO.broken", withExtension: "json")!
_ = try FileDict(contentsOf: fileURL, type: .json, readonly: true)
wait(for: [expectation], timeout: 1.0)
let dict = FileDict(contentsOf: fileURL, type: .json, readonly: true)
do {
try await dict.load(fileURL: fileURL)
XCTFail("エラーが発生するはずなのに発生していない")
} catch {
// OK
}
}

func testAdd() throws {
let dict = try FileDict(contentsOf: fileURL, type: .traditional(.utf8), readonly: true)
@MainActor func testAdd() async throws {
let dict = FileDict(contentsOf: fileURL, type: .traditional(.utf8), readonly: true)
try await dict.load(fileURL: fileURL)
XCTAssertEqual(dict.entryCount, 0)
let word = Word("井")
XCTAssertFalse(dict.hasUnsavedChanges)
Expand All @@ -58,17 +65,19 @@ final class FileDictTests: XCTestCase {
XCTAssertTrue(dict.hasUnsavedChanges)
}

func testDelete() throws {
let dict = try FileDict(contentsOf: fileURL, type: .traditional(.utf8), readonly: true)
@MainActor func testDelete() async throws {
let dict = FileDict(contentsOf: fileURL, type: .traditional(.utf8), readonly: true)
try await dict.load(fileURL: fileURL)
dict.setEntries(["あr": [Word("有"), Word("在")]], readonly: true)
XCTAssertFalse(dict.delete(yomi: "あr", word: "或"))
XCTAssertFalse(dict.hasUnsavedChanges)
XCTAssertTrue(dict.delete(yomi: "あr", word: "在"))
XCTAssertTrue(dict.hasUnsavedChanges)
}

func testSerialize() throws {
let dict = try FileDict(contentsOf: fileURL, type: .traditional(.utf8), readonly: false)
@MainActor func testSerialize() async throws {
let dict = FileDict(contentsOf: fileURL, type: .traditional(.utf8), readonly: false)
try await dict.load(fileURL: fileURL)
XCTAssertEqual(dict.serialize(),
[FileDict.headers[0], FileDict.okuriAriHeader, FileDict.okuriNashiHeader, ""].joined(separator: "\n"))
dict.add(yomi: "あ", word: Word("亜", annotation: Annotation(dictId: "testDict", text: "亜の注釈")))
Expand Down
10 changes: 5 additions & 5 deletions macSKKTests/MemoryDictTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ class MemoryDictTests: XCTestCase {
XCTAssertNil(dict.entries["から"])
}

func testAdd() throws {
@MainActor func testAdd() throws {
var dict = MemoryDict(entries: [:], readonly: false)
XCTAssertEqual(dict.entryCount, 0)
let word1 = Word("井")
Expand Down Expand Up @@ -120,7 +120,7 @@ class MemoryDictTests: XCTestCase {
XCTAssertEqual(dict.refer("いt", option: nil), [Word("行", okuri: "った"), Word("行")])
}

func testDelete() throws {
@MainActor func testDelete() throws {
var dict = MemoryDict(entries: ["あr": [Word("有"), Word("在")], "え": [Word("絵"), Word("柄")]], readonly: false)
XCTAssertFalse(dict.entries.isEmpty)
XCTAssertEqual(dict.okuriAriYomis, ["あr"])
Expand All @@ -145,14 +145,14 @@ class MemoryDictTests: XCTestCase {
XCTAssertTrue(dict.entries.isEmpty)
}

func testDeleteOkuriBlock() throws {
@MainActor func testDeleteOkuriBlock() throws {
var dict = MemoryDict(entries: ["あr": [Word("有", okuri: "る"), Word("有", okuri: "り"), Word("有")]], readonly: false)
XCTAssertTrue(dict.delete(yomi: "あr", word: "有"))
XCTAssertEqual(dict.refer("あr", option: nil), [], "あr を読みとして持つ変換候補が全て削除された")
XCTAssertEqual(dict.okuriAriYomis, [])
}

func testFindCompletion() throws {
@MainActor func testFindCompletion() throws {
var dict = MemoryDict(entries: [:], readonly: false)
XCTAssertNil(dict.findCompletion(prefix: ""), "辞書が空だとnil")
dict.add(yomi: "あいうえおか", word: Word("アイウエオカ"))
Expand All @@ -166,7 +166,7 @@ class MemoryDictTests: XCTestCase {
XCTAssertNil(dict.findCompletion(prefix: "だい"), "数値変換の読みはnil")
}

func testReferWithOption() {
@MainActor func testReferWithOption() {
let dict = MemoryDict(entries: ["あき>": [Word("空き")],
"あき": [Word("秋")],
">し": [Word("氏")],
Expand Down
Loading
Loading