├── Screenshots ├── logo.png └── preview-dark.png ├── VastWords ├── Resources │ └── Assets.xcassets │ │ ├── Contents.json │ │ └── AppIcon.appiconset │ │ ├── icon_16x16.png │ │ ├── icon_32x32.png │ │ ├── icon_128x128.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_512x512.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_512x512@2x.png │ │ └── Contents.json ├── Sources │ ├── Constants │ │ ├── Typography.swift │ │ └── Spacing.swift │ ├── Views │ │ ├── LimitationsView.swift │ │ ├── EmptyStateView.swift │ │ ├── BottomBarView.swift │ │ ├── SettingsView.swift │ │ ├── WordListView.swift │ │ └── StatisticsView.swift │ ├── Models │ │ └── Word.swift │ ├── ContentView.swift │ ├── VastWordsApp.swift │ ├── Services │ │ ├── SystemDictionaryService.swift │ │ ├── OCRService.swift │ │ ├── ClipboardManager.swift │ │ ├── WordRepository.swift │ │ └── WordExtractor.swift │ └── ViewModels │ │ └── WordListViewModel.swift └── Tests │ ├── DictionaryServiceTests.swift │ └── WordExtractorTests.swift ├── LICENSE ├── .gitignore ├── .github └── workflows │ └── release.yml ├── README_zh.md ├── README.md └── Project.swift /Screenshots/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/VastWords/HEAD/Screenshots/logo.png -------------------------------------------------------------------------------- /Screenshots/preview-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/VastWords/HEAD/Screenshots/preview-dark.png -------------------------------------------------------------------------------- /VastWords/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/VastWords/HEAD/VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/VastWords/HEAD/VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/VastWords/HEAD/VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/VastWords/HEAD/VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/VastWords/HEAD/VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/VastWords/HEAD/VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/VastWords/HEAD/VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/VastWords/HEAD/VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/VastWords/HEAD/VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/VastWords/HEAD/VastWords/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /VastWords/Sources/Constants/Typography.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// 全局字体样式常量 4 | enum Typography { 5 | /// 正文文字 6 | static let body = Font.body 7 | /// 标题文字 8 | static let title = Font.headline 9 | /// 副标题文字 10 | static let subtitle = Font.subheadline 11 | /// 按钮文字 12 | static let button = Font.callout 13 | /// 小标签文字 14 | static let caption = Font.caption 15 | } -------------------------------------------------------------------------------- /VastWords/Sources/Views/LimitationsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// 使用限制说明视图 4 | struct LimitationsView: View { 5 | var body: some View { 6 | HStack(spacing: 8) { 7 | Image(systemName: "exclamationmark.circle") 8 | Text("文本长度上限 10,000 字符,单词长度 2-45 字符") 9 | } 10 | .foregroundColor(.primary) 11 | .padding(.vertical, 8) 12 | } 13 | } 14 | 15 | #Preview { 16 | LimitationsView() 17 | } 18 | -------------------------------------------------------------------------------- /VastWords/Sources/Constants/Spacing.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 全局间距常量 4 | enum Spacing { 5 | /// 无间距 (0) 6 | static let none: CGFloat = 0 7 | /// 超小间距 (2) 8 | static let tiny: CGFloat = 2 9 | /// 小间距 (4) 10 | static let small: CGFloat = 4 11 | /// 中间距 (6) 12 | static let medium: CGFloat = 6 13 | /// 大间距 (8) 14 | static let large: CGFloat = 8 15 | /// 超大间距 (12) 16 | static let extraLarge: CGFloat = 12 17 | } -------------------------------------------------------------------------------- /VastWords/Sources/Models/Word.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreStore 3 | 4 | // MARK: - Word 模型 5 | final class Word: CoreStoreObject { 6 | // MARK: - 属性 7 | @Field.Stored("text") 8 | var text: String = "" 9 | 10 | @Field.Stored("count") 11 | var count: Int = 0 12 | 13 | @Field.Stored("stars") 14 | var stars: Int = 0 15 | 16 | @Field.Stored("createdAt") 17 | var createdAt: Date = .init() 18 | 19 | @Field.Stored("updatedAt") 20 | var updatedAt: Date = .init() 21 | 22 | // MARK: - 配置 23 | public class var uniqueConstraints: [[String]] { 24 | return [ 25 | ["text"] // 单词文本必须唯一 26 | ] 27 | } 28 | } -------------------------------------------------------------------------------- /VastWords/Sources/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUIX 3 | 4 | struct ContentView: View { 5 | @EnvironmentObject private var viewModel: WordListViewModel 6 | 7 | var body: some View { 8 | VStack(spacing: 0) { 9 | WordListView() 10 | .minHeight(300) 11 | 12 | Divider() 13 | 14 | StatisticsView() 15 | 16 | Divider() 17 | 18 | SettingsView() 19 | 20 | Divider() 21 | 22 | // LimitationsView() 23 | // 24 | // Divider() 25 | 26 | BottomBarView() 27 | } 28 | .focusable(false) 29 | } 30 | } 31 | 32 | #Preview { 33 | ContentView() 34 | .environmentObject(WordListViewModel(repository: .shared)) 35 | } 36 | -------------------------------------------------------------------------------- /VastWords/Sources/Views/EmptyStateView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// 空状态视图 4 | struct EmptyStateView: View { 5 | let title: String 6 | let systemImage: String 7 | let description: String 8 | 9 | var body: some View { 10 | VStack(spacing: 16) { 11 | Image(systemName: systemImage) 12 | .font(.system(size: 48)) 13 | .foregroundStyle(.secondary) 14 | 15 | Text(title) 16 | .font(.title2) 17 | .foregroundStyle(.primary) 18 | 19 | Text(description) 20 | .font(.body) 21 | .foregroundStyle(.secondary) 22 | .multilineTextAlignment(.center) 23 | } 24 | .padding() 25 | .frame(maxWidth: .infinity, maxHeight: .infinity) 26 | .background(Color(NSColor.windowBackgroundColor)) 27 | } 28 | } -------------------------------------------------------------------------------- /VastWords/Sources/VastWordsApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUIX 3 | 4 | @main 5 | struct VastWordsApp: App { 6 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 7 | @StateObject private var viewModel = WordListViewModel(repository: .shared) 8 | 9 | init() { 10 | // 启动剪贴板监听 11 | ClipboardManager.shared.startMonitoring() 12 | } 13 | 14 | var body: some Scene { 15 | MenuBarExtra { 16 | ContentView() 17 | .environmentObject(viewModel) 18 | } label: { 19 | Image(systemName: "text.word.spacing") 20 | } 21 | .menuBarExtraStyle(.window) 22 | } 23 | } 24 | 25 | class AppDelegate: NSObject, NSApplicationDelegate { 26 | func applicationDidFinishLaunching(_ notification: Notification) { 27 | // 禁用主窗口 28 | NSApp.setActivationPolicy(.accessory) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ygsgdbd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /VastWords/Sources/Services/SystemDictionaryService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AppKit 3 | import CoreServices 4 | 5 | /// 系统词典服务 6 | final class SystemDictionaryService { 7 | static let shared = SystemDictionaryService() 8 | 9 | /// 缓存词典查询结果 10 | private let cache: NSCache = { 11 | let cache = NSCache() 12 | cache.countLimit = 2000 13 | return cache 14 | }() 15 | 16 | private init() {} 17 | 18 | /// 查询单词释义 19 | /// - Parameter word: 要查询的单词 20 | /// - Returns: 单词释义,如果没有找到则返回 nil 21 | func lookup(_ word: String) async -> String? { 22 | // 先从缓存中查找 23 | if let cached = cache.object(forKey: word as NSString) { 24 | return cached as String 25 | } 26 | 27 | // 缓存未命中,从系统词典查询 28 | guard let definition = DCSCopyTextDefinition(nil, word as CFString, CFRangeMake(0, word.count)) else { 29 | return nil 30 | } 31 | 32 | let result = definition.takeRetainedValue() as String 33 | 34 | // 缓存查询结果 35 | cache.setObject(result as NSString, forKey: word as NSString) 36 | 37 | return result 38 | } 39 | 40 | /// 在词典应用中查看单词 41 | func lookupInDictionary(_ word: String) { 42 | let workspace = NSWorkspace.shared 43 | let url = URL(string: "dict://\(word)")! 44 | workspace.open(url) 45 | } 46 | 47 | /// 清除缓存 48 | func clearCache() { 49 | cache.removeAllObjects() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### macOS ### 2 | # General 3 | .DS_Store 4 | .AppleDouble 5 | .LSOverride 6 | 7 | # Icon must end with two 8 | Icon 9 | 10 | # Thumbnails 11 | ._* 12 | 13 | # Files that might appear in the root of a volume 14 | .DocumentRevisions-V100 15 | .fseventsd 16 | .Spotlight-V100 17 | .TemporaryItems 18 | .Trashes 19 | .VolumeIcon.icns 20 | .com.apple.timemachine.donotpresent 21 | 22 | # Directories potentially created on remote AFP share 23 | .AppleDB 24 | .AppleDesktop 25 | Network Trash Folder 26 | Temporary Items 27 | .apdisk 28 | 29 | ### Xcode ### 30 | # Xcode 31 | # 32 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 33 | 34 | ## User settings 35 | xcuserdata/ 36 | 37 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 38 | *.xcscmblueprint 39 | *.xccheckout 40 | 41 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 42 | build/ 43 | DerivedData/ 44 | *.moved-aside 45 | *.pbxuser 46 | !default.pbxuser 47 | *.mode1v3 48 | !default.mode1v3 49 | *.mode2v3 50 | !default.mode2v3 51 | *.perspectivev3 52 | !default.perspectivev3 53 | 54 | ### Xcode Patch ### 55 | *.xcodeproj/* 56 | !*.xcodeproj/project.pbxproj 57 | !*.xcodeproj/xcshareddata/ 58 | !*.xcworkspace/contents.xcworkspacedata 59 | /*.gcno 60 | 61 | ### Projects ### 62 | *.xcodeproj 63 | *.xcworkspace 64 | 65 | ### Tuist derived files ### 66 | graph.dot 67 | Derived/ 68 | 69 | ### Tuist managed dependencies ### 70 | Tuist/.build 71 | 72 | # Build 73 | *.dmg 74 | 75 | # Swift Package Manager 76 | .build/ 77 | .swiftpm/ 78 | .package.resolved -------------------------------------------------------------------------------- /VastWords/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /VastWords/Tests/DictionaryServiceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import VastWords 3 | 4 | final class DictionaryServiceTests: XCTestCase { 5 | var service: DictionaryService! 6 | 7 | override func setUp() { 8 | super.setUp() 9 | service = DictionaryService.shared 10 | } 11 | 12 | override func tearDown() { 13 | service.clearCache() 14 | service = nil 15 | super.tearDown() 16 | } 17 | 18 | func testLookupValidWord() { 19 | // 测试查询一个有效的单词 20 | let definition = service.lookup("hello") 21 | XCTAssertNotNil(definition, "应该能找到 'hello' 的释义") 22 | XCTAssertFalse(definition!.isEmpty, "释义不应该为空") 23 | } 24 | 25 | func testLookupInvalidWord() { 26 | // 测试查询一个无效的单词 27 | let definition = service.lookup("asdfghjkl") 28 | XCTAssertNil(definition, "无效单词应该返回 nil") 29 | } 30 | 31 | func testCaching() { 32 | // 第一次查询 33 | let firstResult = service.lookup("test") 34 | XCTAssertNotNil(firstResult, "应该能找到 'test' 的释义") 35 | 36 | // 第二次查询应该使用缓存 37 | let secondResult = service.lookup("test") 38 | XCTAssertEqual(firstResult, secondResult, "缓存的结果应该相同") 39 | } 40 | 41 | func testCacheClear() { 42 | // 先查询一个单词 43 | let firstResult = service.lookup("test") 44 | XCTAssertNotNil(firstResult) 45 | 46 | // 清除缓存 47 | service.clearCache() 48 | 49 | // 再次查询同一个单词 50 | let secondResult = service.lookup("test") 51 | XCTAssertNotNil(secondResult) 52 | // 结果应该相同,但是是重新查询的 53 | XCTAssertEqual(firstResult, secondResult) 54 | } 55 | } -------------------------------------------------------------------------------- /VastWords/Sources/Services/OCRService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Vision 3 | import AppKit 4 | 5 | /// OCR 相关错误 6 | enum OCRError: Error { 7 | case noImageInClipboard 8 | case invalidImage 9 | case recognitionFailed(Error) 10 | } 11 | 12 | actor OCRService { 13 | static let shared = OCRService() 14 | 15 | private init() {} 16 | 17 | /// 从剪贴板图片中提取文本 18 | /// - Returns: 提取的文本 19 | /// - Throws: OCRError 20 | func extractTextFromClipboard() async throws -> String? { 21 | guard let image = NSPasteboard.general.readObjects(forClasses: [NSImage.self], options: nil)?.first as? NSImage else { 22 | throw OCRError.noImageInClipboard 23 | } 24 | 25 | guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { 26 | throw OCRError.invalidImage 27 | } 28 | 29 | return try await extractText(from: cgImage) 30 | } 31 | 32 | /// 从图片中提取文本 33 | /// - Parameter cgImage: 要处理的图片 34 | /// - Returns: 提取的文本 35 | /// - Throws: OCRError 36 | private func extractText(from cgImage: CGImage) async throws -> String? { 37 | let requestHandler = VNImageRequestHandler(cgImage: cgImage, options: [:]) 38 | let request = VNRecognizeTextRequest() 39 | request.recognitionLanguages = ["en"] 40 | request.recognitionLevel = .accurate 41 | request.usesLanguageCorrection = true 42 | 43 | do { 44 | try requestHandler.perform([request]) 45 | guard let observations = request.results else { return nil } 46 | 47 | return observations.compactMap { observation in 48 | observation.topCandidates(1).first?.string 49 | }.joined(separator: " ") 50 | } catch { 51 | throw OCRError.recognitionFailed(error) 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /VastWords/Sources/Views/BottomBarView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct BottomBarView: View { 4 | @EnvironmentObject private var viewModel: WordListViewModel 5 | 6 | private let repositoryURL = URL(string: "https://github.com/ygsgdbd/VastWords")! 7 | 8 | var body: some View { 9 | HStack { 10 | Button { 11 | let alert = NSAlert() 12 | alert.messageText = "清空全部单词" 13 | alert.informativeText = "确定要清空所有单词吗?此操作不可恢复。" 14 | alert.alertStyle = .warning 15 | alert.addButton(withTitle: "清空") 16 | alert.addButton(withTitle: "取消") 17 | 18 | if alert.runModal() == .alertFirstButtonReturn { 19 | viewModel.removeAll() 20 | } 21 | } label: { 22 | HStack(spacing: 4) { 23 | Image(systemName: "trash") 24 | Text("清空全部") 25 | } 26 | } 27 | .buttonStyle(.plain) 28 | .font(Typography.caption) 29 | .tint(.red) 30 | .foregroundStyle(.red) 31 | 32 | Button { 33 | NSWorkspace.shared.open(repositoryURL) 34 | } label: { 35 | HStack(spacing: 4) { 36 | Image(systemName: "link") 37 | Text("代码仓库") 38 | } 39 | } 40 | .buttonStyle(.plain) 41 | .font(Typography.caption) 42 | .foregroundColor(.secondary) 43 | 44 | Spacer() 45 | 46 | Button { 47 | NSApplication.shared.terminate(nil) 48 | } label: { 49 | Text("退出") 50 | + Text(" (⌘Q)") 51 | .foregroundColor(.secondary.opacity(0.7)) 52 | } 53 | .buttonStyle(.plain) 54 | .keyboardShortcut("q", modifiers: .command) 55 | .font(Typography.caption) 56 | .foregroundColor(.secondary) 57 | } 58 | .padding(.horizontal, Spacing.extraLarge) 59 | .padding(.vertical, Spacing.large) 60 | } 61 | } 62 | 63 | #Preview { 64 | BottomBarView() 65 | .environmentObject(WordListViewModel(repository: .shared)) 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | discussions: write 11 | 12 | jobs: 13 | build: 14 | name: Build and Release 15 | runs-on: macos-14 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Setup Xcode 24 | uses: maxim-lobanov/setup-xcode@v1 25 | with: 26 | xcode-version: '16.1' 27 | 28 | - name: Install Tuist 29 | run: | 30 | brew install tuist 31 | 32 | - name: Generate Xcode Project 33 | run: | 34 | # 生成基于时间戳的构建号(格式:YYYYMMDDHHmm) 35 | BUILD_NUMBER=$(date "+%Y%m%d%H%M") 36 | # 替换 Project.swift 中的占位符 37 | sed -i '' "s/@BUILD_NUMBER@/$BUILD_NUMBER/g" Project.swift 38 | echo "Build number set to: $BUILD_NUMBER" 39 | tuist generate --no-open 40 | 41 | - name: Build App 42 | run: | 43 | xcodebuild \ 44 | -workspace VastWords.xcworkspace \ 45 | -scheme VastWords \ 46 | -configuration Release \ 47 | -derivedDataPath ./DerivedData \ 48 | -arch arm64 -arch x86_64 \ 49 | clean build CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO 50 | 51 | - name: Create DMG 52 | run: | 53 | brew install create-dmg 54 | create-dmg \ 55 | --volname "VastWords" \ 56 | --window-size 500 300 \ 57 | --icon-size 100 \ 58 | --icon "VastWords.app" 150 150 \ 59 | --app-drop-link 350 150 \ 60 | --no-internet-enable \ 61 | "VastWords.dmg" \ 62 | "DerivedData/Build/Products/Release/VastWords.app" 63 | 64 | - name: Generate Checksums 65 | run: | 66 | echo "### VastWords ${{ github.ref_name }}" > checksums.txt 67 | echo "" >> checksums.txt 68 | echo "- Universal Binary (Apple Silicon + Intel)" >> checksums.txt 69 | echo "- macOS 13.0+" >> checksums.txt 70 | echo "" >> checksums.txt 71 | echo "### SHA-256 Checksums" >> checksums.txt 72 | echo "\`\`\`" >> checksums.txt 73 | shasum -a 256 VastWords.dmg >> checksums.txt 74 | echo "\`\`\`" >> checksums.txt 75 | 76 | - name: Release 77 | uses: softprops/action-gh-release@v1 78 | if: startsWith(github.ref, 'refs/tags/v') 79 | with: 80 | files: | 81 | VastWords.dmg 82 | checksums.txt 83 | body_path: checksums.txt 84 | draft: false 85 | prerelease: ${{ contains(github.ref, '-beta') || contains(github.ref, '-alpha') }} 86 | generate_release_notes: true 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | -------------------------------------------------------------------------------- /VastWords/Tests/WordExtractorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import VastWords 3 | 4 | final class WordExtractorTests: XCTestCase { 5 | var extractor: WordExtractor! 6 | 7 | override func setUp() { 8 | super.setUp() 9 | extractor = WordExtractor.shared 10 | } 11 | 12 | override func tearDown() { 13 | extractor = nil 14 | super.tearDown() 15 | } 16 | 17 | // MARK: - 基本功能测试 18 | 19 | func testExtractEmptyText() { 20 | let result = extractor.extract(from: "") 21 | XCTAssertTrue(result.isEmpty, "空文本应该返回空集合") 22 | } 23 | 24 | func testExtractSingleWord() { 25 | let result = extractor.extract(from: "hello") 26 | XCTAssertEqual(result, ["hello"], "应该正确提取单个单词") 27 | } 28 | 29 | func testExtractMultipleWords() { 30 | let result = extractor.extract(from: "hello world") 31 | XCTAssertEqual(result, ["hello", "world"], "应该正确提取多个单词") 32 | } 33 | 34 | // MARK: - 词形还原测试 35 | 36 | func testLemmatization() { 37 | let result = extractor.extract(from: "running runs ran") 38 | XCTAssertEqual(result, ["run"], "应该将所有动词形式还原为原形") 39 | 40 | let result2 = extractor.extract(from: "mice mouse") 41 | XCTAssertEqual(result2, ["mouse"], "应该将复数形式还原为单数") 42 | } 43 | 44 | // MARK: - 语言检测测试 45 | 46 | func testNonEnglishText() { 47 | let result = extractor.extract(from: "你好世界") 48 | XCTAssertTrue(result.isEmpty, "非英文文本应该返回空集合") 49 | 50 | let result2 = extractor.extract(from: "Bonjour le monde") 51 | XCTAssertTrue(result2.isEmpty, "法语文本应该返回空集合") 52 | } 53 | 54 | func testMixedLanguageText() { 55 | let result = extractor.extract(from: "hello 世界 world") 56 | XCTAssertEqual(result, ["hello", "world"], "应该只提取英文单词") 57 | } 58 | 59 | // MARK: - 特殊情况测试 60 | 61 | func testPunctuation() { 62 | let result = extractor.extract(from: "hello, world! How are you?") 63 | XCTAssertEqual(result, ["hello", "world", "how", "be", "you"], "应该正确处理标点符号") 64 | } 65 | 66 | func testWhitespace() { 67 | let result = extractor.extract(from: " hello world ") 68 | XCTAssertEqual(result, ["hello", "world"], "应该正确处理空白字符") 69 | } 70 | 71 | func testCase() { 72 | let result = extractor.extract(from: "Hello WORLD") 73 | XCTAssertEqual(result, ["hello", "world"], "应该将所有单词转换为小写") 74 | } 75 | 76 | // MARK: - 复杂文本测试 77 | 78 | func testComplexText() { 79 | let text = """ 80 | Hello, World! This is a complex text with multiple sentences. 81 | It includes numbers like 123 and special characters @#$. 82 | Some words are UPPERCASE, some are lowercase, and some are Mixed. 83 | """ 84 | 85 | let result = extractor.extract(from: text) 86 | let expectedWords = [ 87 | "hello", "world", "this", "be", "complex", "text", "with", "multiple", 88 | "sentence", "it", "include", "number", "like", "and", "special", 89 | "character", "some", "word", "uppercase", "lowercase", "mixed" 90 | ] 91 | 92 | for word in expectedWords { 93 | XCTAssertTrue(result.contains(word), "复杂文本中应该包含单词: \(word)") 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 | # VastWords 📚 2 | 3 |
4 | VastWords Logo 5 |
6 | 7 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/ygsgdbd/VastWords)](https://github.com/ygsgdbd/VastWords/releases) 8 | [![GitHub](https://img.shields.io/github/license/ygsgdbd/VastWords)](https://github.com/ygsgdbd/VastWords/blob/main/LICENSE) 9 | [![Platform](https://img.shields.io/badge/platform-macOS%2013%2B-brightgreen)](https://github.com/ygsgdbd/VastWords) 10 | [![Swift](https://img.shields.io/badge/Swift-5.9-orange.svg)](https://swift.org) 11 | [![Xcode](https://img.shields.io/badge/Xcode-15.0%2B-blue)](https://developer.apple.com/xcode/) 12 | [![SwiftUI](https://img.shields.io/badge/SwiftUI-3.0-blue)](https://developer.apple.com/xcode/swiftui) 13 | 14 | 🇨🇳 中文 | [English](README.md) 15 | 16 | VastWords 是一个 macOS 单词本应用,帮助你快速积累日常阅读中遇到的单词。它能够自动监听剪贴板中的英文单词,并提供系统词典查询功能。应用完全离线运行,所有数据均存储在本地,确保您的隐私安全。 17 | 18 |
19 | VastWords Preview 20 |
21 | 22 | ## 📥 安装方式 23 | 24 | ### 🍺 通过 Homebrew 安装(推荐) 25 | 26 | ```bash 27 | brew install ygsgdbd/tap/vastwords 28 | ``` 29 | 30 | ### 💻 手动安装 31 | 32 | 1. 从 [Releases](https://github.com/ygsgdbd/VastWords/releases) 页面下载最新版本 33 | 2. 将 VastWords.app 拖入应用程序文件夹 34 | 3. 从应用程序文件夹或 Spotlight 启动 VastWords 35 | 36 | ## 🛠 技术栈 37 | 38 | - SwiftUI + MVVM 架构 39 | - CoreData + CoreStore 数据持久化 40 | - Vision 框架实现 OCR 41 | - Natural Language 实现词形还原 42 | - Combine + Async/Await 异步处理 43 | - Tuist 项目管理 44 | - SwiftLint + SwiftFormat 代码规范 45 | 46 | ## ✨ 功能特点 47 | 48 | - 🔄 智能监听功能 49 | - 自动监听剪贴板中的文本 50 | - 支持图片自动 OCR 识别(使用系统内置 Vision 框架,离线且安全) 51 | - 自动提取英文单词 52 | - 智能还原单词原形(如 running → run, cities → city) 53 | - 📚 集成 macOS 系统词典,实时查询单词释义 54 | - ⭐️ 支持为重要单词添加星标 55 | - 🔍 支持单词搜索和筛选 56 | - 📊 展示最近 24 小时单词收集统计 57 | - 📥 支持导出单词列表 58 | - 🚀 支持开机自启动 59 | - ⚡️ 高性能存储 60 | - 使用 CoreData 进行数据持久化 61 | - 支持快速检索和更新 62 | - 内存占用小,响应迅速 63 | - 🎯 性能优化 64 | - 后台静默运行,低优先级处理 65 | - 智能资源管理,不影响其他应用 66 | - 内存占用小,CPU 使用率低 67 | 68 | ## 🔐 隐私与安全 69 | 70 | - 🔒 完全离线运行,无需网络连接 71 | - 💾 所有数据存储在本地,不会上传到云端 72 | - 🛡️ 使用系统内置功能 73 | - Vision 框架进行图片 OCR 74 | - macOS 系统词典查询 75 | - Natural Language 框架进行词形还原 76 | - CoreData 高性能数据存储 77 | - 🤝 不收集任何用户数据 78 | - 📱 不需要任何权限,除了 79 | - 剪贴板访问(用于监听单词) 80 | - 开机自启动(可选) 81 | - 💪 系统友好 82 | - 后台任务使用低优先级 83 | - 自动调节资源占用 84 | - 不影响用户正常工作 85 | 86 | ## ⚠️ 使用限制 87 | 88 | - 文本长度上限为 10,000 字符 89 | - 单词长度限制为 2-45 个字符 90 | - 自动过滤常见功能词(如 a, the, in 等) 91 | 92 | ## 💻 系统要求 93 | 94 | - macOS 13.0 或更高版本 95 | - Apple Silicon 或 Intel 处理器 96 | 97 | ## 🔧 开发环境 98 | 99 | - Xcode 15.0+ 100 | - Swift 5.9+ 101 | - SwiftUI 102 | - [Tuist](https://tuist.io) 3.0+ 103 | 104 | ## 📦 第三方依赖 105 | 106 | - [CoreStore](https://github.com/JohnEstropia/CoreStore) - CoreData 数据库管理 107 | - [Defaults](https://github.com/sindresorhus/Defaults) - 用户偏好设置存储 108 | - [SwifterSwift](https://github.com/SwifterSwift/SwifterSwift) - Swift 扩展集合 109 | - [SwiftUIX](https://github.com/SwiftUIX/SwiftUIX) - SwiftUI 功能扩展 110 | 111 | ## 📄 许可证 112 | 113 | 本项目采用 MIT 许可证开源。这意味着你可以自由地使用、修改和分发本项目,但需要保留原始许可证和版权信息。详见 [LICENSE](LICENSE) 文件。 114 | 115 | ### 📝 第三方许可证 116 | 117 | 本项目使用了以下开源组件: 118 | 119 | - [CoreStore](https://github.com/JohnEstropia/CoreStore) - MIT License 120 | - [Defaults](https://github.com/sindresorhus/Defaults) - MIT License 121 | - [SwifterSwift](https://github.com/SwifterSwift/SwifterSwift) - MIT License 122 | - [SwiftUIX](https://github.com/SwiftUIX/SwiftUIX) - MIT License 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VastWords 📚 2 | 3 |
4 | VastWords Logo 5 |
6 | 7 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/ygsgdbd/VastWords)](https://github.com/ygsgdbd/VastWords/releases) 8 | [![GitHub](https://img.shields.io/github/license/ygsgdbd/VastWords)](https://github.com/ygsgdbd/VastWords/blob/main/LICENSE) 9 | [![Platform](https://img.shields.io/badge/platform-macOS%2013%2B-brightgreen)](https://github.com/ygsgdbd/VastWords) 10 | [![Swift](https://img.shields.io/badge/Swift-5.9-orange.svg)](https://swift.org) 11 | [![Xcode](https://img.shields.io/badge/Xcode-15.0%2B-blue)](https://developer.apple.com/xcode/) 12 | [![SwiftUI](https://img.shields.io/badge/SwiftUI-3.0-blue)](https://developer.apple.com/xcode/swiftui) 13 | 14 | [🇨🇳 中文文档](README_zh.md) | English 15 | 16 | VastWords is a macOS vocabulary application designed to help you quickly collect and learn English words encountered during daily reading. It automatically monitors the clipboard for English words and provides system dictionary lookup functionality. The application runs completely offline, with all data stored locally to ensure your privacy. 17 | 18 |
19 | VastWords Preview 20 |
21 | 22 | ## 📥 Installation 23 | 24 | ### 🍺 Via Homebrew (Recommended) 25 | 26 | ```bash 27 | brew install ygsgdbd/tap/vastwords 28 | ``` 29 | 30 | ### 💻 Manual Installation 31 | 32 | 1. Download the latest release from the [Releases](https://github.com/ygsgdbd/VastWords/releases) page 33 | 2. Drag VastWords.app to your Applications folder 34 | 3. Launch VastWords from Applications or Spotlight 35 | 36 | ## 🛠 Tech Stack 37 | 38 | - SwiftUI + MVVM Architecture 39 | - CoreData + CoreStore for Data Persistence 40 | - Vision Framework for OCR 41 | - Natural Language for Lemmatization 42 | - Combine + Async/Await for Asynchronous Operations 43 | - Tuist for Project Management 44 | - SwiftLint + SwiftFormat for Code Standards 45 | 46 | ## ✨ Features 47 | 48 | - 🔄 Smart Monitoring 49 | - Automatic clipboard text monitoring 50 | - Image OCR support (using system Vision framework, offline and secure) 51 | - Automatic English word extraction 52 | - Smart word lemmatization (e.g., running → run, cities → city) 53 | - 📚 Integrated macOS system dictionary for real-time word definitions 54 | - ⭐️ Star important words 55 | - 🔍 Word search and filtering 56 | - 📊 24-hour word collection statistics 57 | - 📥 Word list export 58 | - 🚀 Launch at login support 59 | - ⭐️ High-performance storage 60 | - 🎯 Performance optimized 61 | 62 | ## 🔐 Privacy & Security 63 | 64 | - 🔒 Completely offline operation 65 | - 💾 Local data storage only 66 | - 🛡️ Uses system built-in features 67 | - 🤝 No user data collection 68 | - 📱 Minimal permissions required 69 | 70 | ## ⚠️ Limitations 71 | 72 | - Text length limit: 10,000 characters 73 | - Word length limit: 2-45 characters 74 | - Automatic filtering of common function words (e.g., a, the, in) 75 | 76 | ## 💻 System Requirements 77 | 78 | - macOS 13.0 or later 79 | - Apple Silicon or Intel processor 80 | 81 | ## 🔧 Development Setup 82 | 83 | - Xcode 15.0+ 84 | - Swift 5.9+ 85 | - SwiftUI 86 | - [Tuist](https://tuist.io) 3.0+ 87 | 88 | ## 📦 Dependencies 89 | 90 | - [CoreStore](https://github.com/JohnEstropia/CoreStore) 91 | - [Defaults](https://github.com/sindresorhus/Defaults) 92 | - [SwifterSwift](https://github.com/SwifterSwift/SwifterSwift) 93 | - [SwiftUIX](https://github.com/SwiftUIX/SwiftUIX) 94 | 95 | ## 📄 License 96 | 97 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 98 | 99 | ### 📝 Third-Party Licenses 100 | 101 | This project uses the following open source components: 102 | 103 | - [CoreStore](https://github.com/JohnEstropia/CoreStore) - MIT License 104 | - [Defaults](https://github.com/sindresorhus/Defaults) - MIT License 105 | - [SwifterSwift](https://github.com/SwifterSwift/SwifterSwift) - MIT License 106 | - [SwiftUIX](https://github.com/SwiftUIX/SwiftUIX) - MIT License -------------------------------------------------------------------------------- /Project.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | // MARK: - Version 4 | let appVersion = "0.1.0" // 应用版本号 5 | let buildVersion = "@BUILD_NUMBER@" // 构建版本号占位符,会被 GitHub Actions 替换 6 | 7 | let project = Project( 8 | name: "VastWords", 9 | options: .options( 10 | defaultKnownRegions: ["zh-Hans", "zh-Hant", "en"], 11 | developmentRegion: "zh-Hans" 12 | ), 13 | packages: [ 14 | .remote(url: "https://github.com/sindresorhus/Defaults", requirement: .upToNextMajor(from: "9.0.0")), 15 | .remote(url: "https://github.com/SwiftUIX/SwiftUIX", requirement: .upToNextMajor(from: "0.1.9")), 16 | .remote(url: "https://github.com/SwifterSwift/SwifterSwift", requirement: .upToNextMajor(from: "7.0.0")), 17 | .remote(url: "https://github.com/JohnEstropia/CoreStore", requirement: .upToNextMajor(from: "9.3.0")) 18 | ], 19 | settings: .settings( 20 | base: [ 21 | "SWIFT_VERSION": SettingValue(stringLiteral: "5.9"), 22 | "DEVELOPMENT_LANGUAGE": SettingValue(stringLiteral: "zh-Hans"), 23 | "SWIFT_EMIT_LOC_STRINGS": SettingValue(stringLiteral: "YES"), 24 | "MARKETING_VERSION": SettingValue(stringLiteral: appVersion), 25 | "CURRENT_PROJECT_VERSION": SettingValue(stringLiteral: buildVersion) 26 | ], 27 | configurations: [ 28 | .debug(name: "Debug"), 29 | .release(name: "Release") 30 | ] 31 | ), 32 | targets: [ 33 | .target( 34 | name: "VastWords", 35 | destinations: .macOS, 36 | product: .app, 37 | bundleId: "top.ygsgdbd.VastWords", 38 | deploymentTargets: .macOS("13.0"), 39 | infoPlist: .extendingDefault(with: [ 40 | "LSUIElement": true, // 设置为纯菜单栏应用 41 | "CFBundleDevelopmentRegion": "zh-Hans", // 设置默认开发区域为简体中文 42 | "CFBundleLocalizations": ["zh-Hans", "zh-Hant", "en"], // 支持的语言列表 43 | "AppleLanguages": ["zh-Hans"], // 设置默认语言为简体中文 44 | "NSHumanReadableCopyright": "Copyright © 2024 ygsgdbd. All rights reserved.", 45 | "LSApplicationCategoryType": "public.app-category.utilities", 46 | "LSMinimumSystemVersion": "13.0", 47 | "CFBundleShortVersionString": .string(appVersion), // 市场版本号 48 | "CFBundleVersion": .string(buildVersion), // 构建版本号 49 | // 添加开机启动所需权限 50 | "NSAppleEventsUsageDescription": "需要此权限以管理开机启动设置。", 51 | "com.apple.security.automation.apple-events": true 52 | ]), 53 | sources: ["VastWords/Sources/**"], 54 | resources: [ 55 | "VastWords/Resources/**", 56 | // .folderReference(path: "VastWords/Resources/zh-Hans.lproj"), 57 | // .folderReference(path: "VastWords/Resources/zh-Hant.lproj"), 58 | // .folderReference(path: "VastWords/Resources/en.lproj") 59 | ], 60 | dependencies: [ 61 | .package(product: "Defaults"), 62 | .package(product: "SwiftUIX"), 63 | .package(product: "SwifterSwift"), 64 | .package(product: "CoreStore"), 65 | .sdk(name: "CoreServices", type: .framework), 66 | .sdk(name: "ServiceManagement", type: .framework) 67 | ], 68 | settings: .settings( 69 | base: [ 70 | "DEVELOPMENT_LANGUAGE": "zh-Hans", 71 | "SWIFT_VERSION": "5.9", 72 | "SWIFT_EMIT_LOC_STRINGS": "YES" 73 | ], 74 | configurations: [ 75 | .debug(name: "Debug"), 76 | .release(name: "Release") 77 | ] 78 | ) 79 | ), 80 | .target( 81 | name: "VastWordsTests", 82 | destinations: .macOS, 83 | product: .unitTests, 84 | bundleId: "top.ygsgdbd.VastWordsTests", 85 | deploymentTargets: .macOS("13.0"), 86 | infoPlist: .default, 87 | sources: ["VastWords/Tests/**"], 88 | resources: [], 89 | dependencies: [.target(name: "VastWords")] 90 | ), 91 | ] 92 | ) -------------------------------------------------------------------------------- /VastWords/Sources/Services/ClipboardManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AppKit 3 | import Combine 4 | 5 | extension Notification.Name { 6 | static let wordsDidSave = Notification.Name("wordsDidSave") 7 | } 8 | 9 | @MainActor 10 | final class ClipboardManager { 11 | static let shared = ClipboardManager() 12 | 13 | /// 剪贴板检查间隔(纳秒) 14 | private let checkInterval: UInt64 = 500_000_000 // 0.5秒 15 | 16 | private var lastChangeCount: Int 17 | private let pasteboard = NSPasteboard.general 18 | private var cancellables = Set() 19 | 20 | private let repository: WordRepository 21 | private let extractor: WordExtractor 22 | private let dictionaryService: SystemDictionaryService 23 | private let ocrService: OCRService 24 | 25 | private var monitoringTask: Task? 26 | 27 | private init() { 28 | print("📋 ClipboardManager: 初始化") 29 | self.repository = WordRepository.shared 30 | self.extractor = WordExtractor.shared 31 | self.dictionaryService = SystemDictionaryService.shared 32 | self.ocrService = OCRService.shared 33 | self.lastChangeCount = pasteboard.changeCount 34 | } 35 | 36 | func startMonitoring() { 37 | print("📋 ClipboardManager: 开始监听剪贴板(检查间隔:\(Double(checkInterval) / 1_000_000_000)秒)") 38 | 39 | // 取消之前的监听任务 40 | monitoringTask?.cancel() 41 | 42 | // 创建新的监听任务 43 | monitoringTask = Task(priority: .background) { @MainActor in 44 | while !Task.isCancelled { 45 | // 检查剪贴板是否有变化 46 | let currentCount = pasteboard.changeCount 47 | if currentCount != lastChangeCount { 48 | lastChangeCount = currentCount 49 | await processClipboard() 50 | } 51 | 52 | // 等待指定时间再检查 53 | try? await Task.sleep(nanoseconds: checkInterval) 54 | } 55 | } 56 | } 57 | 58 | func stopMonitoring() { 59 | print("📋 ClipboardManager: 停止监听剪贴板") 60 | monitoringTask?.cancel() 61 | monitoringTask = nil 62 | } 63 | 64 | private func processClipboard() async { 65 | // 先尝试获取文本 66 | if let text = pasteboard.string(forType: .string) { 67 | await processText(text) 68 | return 69 | } 70 | 71 | // 如果没有文本,尝试处理图片 72 | do { 73 | if let imageText = try await ocrService.extractTextFromClipboard() { 74 | print("📋 ClipboardManager: OCR 提取文本成功") 75 | await processText(imageText) 76 | } 77 | } catch OCRError.noImageInClipboard { 78 | // 剪贴板中没有图片,忽略 79 | return 80 | } catch { 81 | print("⚠️ ClipboardManager: OCR 处理失败: \(error)") 82 | } 83 | } 84 | 85 | private func processText(_ text: String) async { 86 | do { 87 | // 提取单词 88 | let words = await extractor.extract(from: text) 89 | guard !words.isEmpty else { return } 90 | 91 | print("📋 ClipboardManager: 发现 \(words.count) 个单词") 92 | 93 | // 验证单词是否有效 94 | var validWords = Set() 95 | 96 | await withThrowingTaskGroup(of: (String, Bool).self, body: { group in 97 | for word in words { 98 | group.addTask(priority: .background) { 99 | let definition = await self.dictionaryService.lookup(word) 100 | return (word, definition != nil) 101 | } 102 | } 103 | 104 | do { 105 | for try await (word, isValid) in group { 106 | if isValid { 107 | validWords.insert(word) 108 | } 109 | } 110 | } catch { 111 | print("⚠️ ClipboardManager: 单词验证失败: \(error)") 112 | } 113 | }) 114 | 115 | guard !validWords.isEmpty else { return } 116 | 117 | // 保存单词 118 | try repository.batchSave(validWords) 119 | print("📋 ClipboardManager: 保��� \(validWords.count) 个有效单词成功") 120 | NotificationCenter.default.post(name: .wordsDidSave, object: nil) 121 | } catch { 122 | print("⚠️ ClipboardManager: 处理失败: \(error)") 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /VastWords/Sources/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingsView: View { 4 | @EnvironmentObject private var viewModel: WordListViewModel 5 | 6 | var body: some View { 7 | Grid(alignment: .leading) { 8 | // 导出设置 9 | GridRow { 10 | // 左侧标题和副标题 11 | VStack(alignment: .leading, spacing: Spacing.small) { 12 | HStack(spacing: Spacing.small) { 13 | Image(systemName: "square.and.arrow.up") 14 | .frame(width: 16) 15 | .font(Typography.subtitle) 16 | 17 | Text("导出单词") 18 | .font(Typography.subtitle) 19 | } 20 | 21 | Text("将收集的单词导出为文本文件") 22 | .font(Typography.caption) 23 | .foregroundStyle(.secondary) 24 | } 25 | .gridCellColumns(1) 26 | 27 | // 右侧功能区域 28 | HStack(spacing: Spacing.medium) { 29 | Button(action: { 30 | viewModel.exportToTxt(starredOnly: true) 31 | }) { 32 | Text("星标(\(viewModel.starredCount))") 33 | .font(Typography.caption) 34 | } 35 | .buttonStyle(.borderless) 36 | 37 | Button(action: { 38 | viewModel.exportToTxt() 39 | }) { 40 | Text("全部(\(viewModel.totalCount))") 41 | .font(Typography.caption) 42 | } 43 | .buttonStyle(.borderless) 44 | } 45 | .frame(maxWidth: .infinity, alignment: .trailing) 46 | .gridCellColumns(1) 47 | } 48 | 49 | Divider() 50 | .gridCellColumns(2) 51 | 52 | // 显示设置 53 | GridRow { 54 | // 左侧标题和副标题 55 | VStack(alignment: .leading, spacing: Spacing.small) { 56 | HStack(spacing: Spacing.small) { 57 | Image(systemName: "text.magnifyingglass") 58 | .frame(width: 16) 59 | .font(Typography.subtitle) 60 | 61 | Text("显示释义") 62 | .font(Typography.subtitle) 63 | } 64 | 65 | Text("在列表中显示系统词典释义") 66 | .font(Typography.caption) 67 | .foregroundStyle(.secondary) 68 | } 69 | .gridCellColumns(1) 70 | 71 | // 右侧功能区域 72 | HStack(spacing: Spacing.medium) { 73 | Toggle(isOn: $viewModel.showDefinition) { 74 | 75 | } 76 | .toggleStyle(.switch) 77 | .controlSize(.mini) 78 | } 79 | .frame(maxWidth: .infinity, alignment: .trailing) 80 | .gridCellColumns(1) 81 | } 82 | 83 | Divider() 84 | .gridCellColumns(2) 85 | 86 | // 启动设置 87 | GridRow { 88 | // 左侧标题和副标题 89 | VStack(alignment: .leading, spacing: Spacing.small) { 90 | HStack(spacing: Spacing.small) { 91 | Image(systemName: "power") 92 | .frame(width: 16) 93 | .font(Typography.subtitle) 94 | 95 | Text("开机启动") 96 | .font(Typography.subtitle) 97 | } 98 | 99 | Text("登录系统时自动启动应用") 100 | .font(Typography.caption) 101 | .foregroundStyle(.secondary) 102 | } 103 | .gridCellColumns(1) 104 | 105 | // 右侧功能区域 106 | HStack(spacing: Spacing.medium) { 107 | Toggle(isOn: $viewModel.launchAtLogin) { 108 | 109 | } 110 | .toggleStyle(.switch) 111 | .controlSize(.mini) 112 | } 113 | .frame(maxWidth: .infinity, alignment: .trailing) 114 | .gridCellColumns(1) 115 | } 116 | } 117 | .frame(maxWidth: .infinity) 118 | .padding(.horizontal, Spacing.extraLarge) 119 | .padding(.vertical, Spacing.medium) 120 | } 121 | } 122 | 123 | #Preview { 124 | SettingsView() 125 | .environmentObject(WordListViewModel(repository: .shared)) 126 | .frame(width: 300) 127 | } 128 | -------------------------------------------------------------------------------- /VastWords/Sources/Services/WordRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreStore 3 | 4 | @MainActor 5 | final class WordRepository { 6 | static let shared = WordRepository() 7 | 8 | private let dataStack: DataStack 9 | 10 | private init() { 11 | dataStack = DataStack( 12 | CoreStoreSchema( 13 | modelVersion: "V1", 14 | entities: [ 15 | Entity("Word") 16 | ] 17 | ) 18 | ) 19 | 20 | do { 21 | try dataStack.addStorageAndWait( 22 | SQLiteStore( 23 | fileName: "words.sqlite", 24 | localStorageOptions: .allowSynchronousLightweightMigration 25 | ) 26 | ) 27 | } catch { 28 | print("⚠️ Failed to add storage: \(error)") 29 | } 30 | } 31 | 32 | /// 保存或更新单词 33 | func save(_ word: String, count: Int = 1, stars: Int = 0) throws { 34 | try dataStack.perform { transaction in 35 | if let existingWord = try transaction.fetchOne( 36 | From() 37 | .where(Where("text == %@", word)) 38 | ) { 39 | existingWord.count += count 40 | existingWord.updatedAt = Date() 41 | } else { 42 | let newWord = transaction.create(Into()) 43 | newWord.text = word 44 | newWord.count = count 45 | newWord.stars = stars 46 | newWord.createdAt = Date() 47 | newWord.updatedAt = Date() 48 | } 49 | } 50 | } 51 | 52 | /// 更新单词星级(不更新时间) 53 | func updateStars(for word: String, stars: Int) throws { 54 | try dataStack.perform { transaction in 55 | if let existingWord = try transaction.fetchOne( 56 | From() 57 | .where(Where("text == %@", word)) 58 | ) { 59 | existingWord.stars = stars 60 | } 61 | } 62 | } 63 | 64 | /// 获取单词 65 | func get(_ word: String) throws -> Word? { 66 | return try dataStack.fetchOne( 67 | From() 68 | .where(Where("text == %@", word)) 69 | ) 70 | } 71 | 72 | /// 获取所有单词 73 | func getAll() throws -> [Word] { 74 | return try dataStack.fetchAll( 75 | From() 76 | .orderBy(.init(NSSortDescriptor(key: "updatedAt", ascending: false))) 77 | ) 78 | } 79 | 80 | /// 获取星标单词 81 | func getStarred() throws -> [Word] { 82 | return try dataStack.fetchAll( 83 | From() 84 | .where(Where("stars > 0")) 85 | .orderBy(.init(NSSortDescriptor(key: "updatedAt", ascending: false))) 86 | ) 87 | } 88 | 89 | /// 获取指定时间范围内的单词数量 90 | func getWordCount(from startDate: Date, to endDate: Date) throws -> Int { 91 | return try dataStack.fetchCount( 92 | From() 93 | .where(Where("updatedAt >= %@ AND updatedAt < %@", startDate, endDate)) 94 | ) 95 | } 96 | 97 | /// 搜索单词 98 | func search(_ query: String) throws -> [Word] { 99 | // 将查询转换为小写并移除多余空格 100 | let normalizedQuery = query.trimmingCharacters(in: .whitespaces).lowercased() 101 | 102 | // 如果查询为空,返回所有单词 103 | guard !normalizedQuery.isEmpty else { 104 | return try getAll() 105 | } 106 | 107 | // 构建模糊搜索条件 108 | let conditions = [ 109 | Where("text == %@", normalizedQuery), 110 | Where("text BEGINSWITH[cd] %@", normalizedQuery), 111 | Where("text CONTAINS[cd] %@", normalizedQuery), 112 | Where("text LIKE[cd] %@", "*\(normalizedQuery)*") 113 | ] 114 | 115 | // 组合所有搜索条件 116 | let combinedCondition = conditions.reduce(Where("FALSEPREDICATE")) { $0 || $1 } 117 | 118 | // 执行搜索并按相关性排序 119 | return try dataStack.fetchAll( 120 | From() 121 | .where(combinedCondition) 122 | .orderBy(.init(NSSortDescriptor(key: "updatedAt", ascending: false))) 123 | ) 124 | } 125 | 126 | /// 删除单词 127 | func remove(_ word: String) throws { 128 | try dataStack.perform { transaction in 129 | if let existingWord = try transaction.fetchOne( 130 | From() 131 | .where(Where("text == %@", word)) 132 | ) { 133 | transaction.delete(existingWord) 134 | } 135 | } 136 | } 137 | 138 | /// 删除所有单词 139 | func removeAll() throws { 140 | try dataStack.perform { transaction in 141 | try transaction.deleteAll(From()) 142 | } 143 | } 144 | 145 | /// 批量保存单词 146 | func batchSave(_ words: Set) throws { 147 | try dataStack.perform { transaction in 148 | for word in words { 149 | if let existingWord = try transaction.fetchOne( 150 | From() 151 | .where(Where("text == %@", word)) 152 | ) { 153 | existingWord.count += 1 154 | existingWord.updatedAt = Date() 155 | } else { 156 | let newWord = transaction.create(Into()) 157 | newWord.text = word 158 | newWord.count = 1 159 | newWord.createdAt = Date() 160 | newWord.updatedAt = Date() 161 | } 162 | } 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /VastWords/Sources/Services/WordExtractor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AppKit 3 | import NaturalLanguage 4 | 5 | /// 单词提取工具 6 | actor WordExtractor { 7 | static let shared = WordExtractor() 8 | 9 | /// 英文字母集合 10 | private let englishLetters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz") 11 | 12 | /// 最小单词长度 13 | private let minimumWordLength = 2 14 | 15 | /// 最大单词长度 16 | private let maximumWordLength = 45 17 | 18 | /// 单次处理的最大文本长度 19 | private let maxBatchLength = 10000 20 | 21 | /// 缓存大小限制 22 | private let maxCacheSize = 1000 23 | 24 | /// 要忽略的常见单词(仅过滤基础功能词) 25 | private let commonWords: Set = [ 26 | // 冠词 27 | "a", "an", "the", 28 | 29 | // 基础代词 30 | "i", "you", "he", "she", "it", "we", "they", 31 | "me", "him", "her", "us", "them", 32 | "my", "your", "his", "its", "our", "their", 33 | "this", "that", "these", "those", 34 | 35 | // 基础介词 36 | "in", "on", "at", "to", "for", "of", "with", 37 | "by", "from", "up", "about", "into", "over", 38 | 39 | // 基础连词 40 | "and", "but", "or", "if", "so", 41 | 42 | // 助动词 43 | "am", "is", "are", "was", "were", 44 | "have", "has", "had", 45 | "do", "does", "did", 46 | 47 | // 其他功能词 48 | "not", "yes", "no", "ok", "okay" 49 | ] 50 | 51 | /// 结果缓存 52 | private var cache = NSCache() 53 | 54 | private let tagger: NSLinguisticTagger 55 | private let options: NSLinguisticTagger.Options 56 | 57 | private init() { 58 | self.tagger = NSLinguisticTagger(tagSchemes: [.tokenType, .lemma, .language], options: 0) 59 | self.options = [.omitPunctuation, .omitWhitespace, .joinNames] 60 | 61 | // 设置缓存限制 62 | cache.countLimit = maxCacheSize 63 | } 64 | 65 | /// 从文本中提取英文单词 66 | /// - Parameter text: 要处理的文本 67 | /// - Returns: 提取的单词数组(已词形还原) 68 | func extract(from text: String) async -> Set { 69 | guard !text.isEmpty else { return [] } 70 | 71 | // 检查缓存 72 | if let cached = cache.object(forKey: text as NSString) { 73 | return cached as! Set 74 | } 75 | 76 | var words = Set() 77 | 78 | // 如果是单个单词,直接处理 79 | if !text.contains(" ") { 80 | if let word = processWord(text) { 81 | words.insert(word) 82 | } 83 | cache.setObject(words as NSSet, forKey: text as NSString) 84 | return words 85 | } 86 | 87 | // 检查语言 88 | tagger.string = text 89 | let language = tagger.dominantLanguage 90 | guard language == "en" || language == nil else { return [] } 91 | 92 | // 分批并发处理长文本 93 | let textLength = text.utf16.count 94 | let batchCount = (textLength + maxBatchLength - 1) / maxBatchLength 95 | 96 | await withTaskGroup(of: Set.self) { group in 97 | for i in 0..() 104 | let localTagger = NSLinguisticTagger(tagSchemes: [.tokenType, .lemma, .language], options: 0) 105 | localTagger.string = text 106 | 107 | // 分词并获取词形还原 108 | let semaphore = DispatchSemaphore(value: 0) 109 | var tempWords = [(String, String?)]() 110 | 111 | localTagger.enumerateTags(in: range, unit: .word, scheme: .lemma, options: options) { tag, tokenRange, stop in 112 | guard let wordRange = Range(tokenRange, in: text) else { return } 113 | let word = String(text[wordRange]) 114 | tempWords.append((word, tag?.rawValue)) 115 | } 116 | 117 | // 处理提取的单词 118 | for (word, lemma) in tempWords { 119 | if let processedWord = processWord(word) { 120 | if let lemma = lemma?.lowercased(), !lemma.isEmpty { 121 | batchWords.insert(lemma) 122 | } else { 123 | batchWords.insert(processedWord) 124 | } 125 | } 126 | } 127 | 128 | return batchWords 129 | } 130 | } 131 | 132 | // 合并所有批次的结果 133 | for await batchWords in group { 134 | words.formUnion(batchWords) 135 | } 136 | } 137 | 138 | // 缓存结果 139 | cache.setObject(words as NSSet, forKey: text as NSString) 140 | return words 141 | } 142 | 143 | /// 处理单个单词 144 | /// - Parameter word: 原始单词 145 | /// - Returns: 处理后的单词,如果不符合要求则返回 nil 146 | nonisolated private func processWord(_ word: String) -> String? { 147 | let processed = word.trimmingCharacters(in: .whitespaces).lowercased() 148 | 149 | // 检查单词是否符合要求 150 | guard !processed.isEmpty, 151 | processed.count >= minimumWordLength, 152 | processed.count <= maximumWordLength, 153 | processed.unicodeScalars.allSatisfy({ englishLetters.contains($0) }), 154 | !commonWords.contains(processed) else { 155 | return nil 156 | } 157 | 158 | return processed 159 | } 160 | 161 | /// 清除缓存 162 | func clearCache() { 163 | cache.removeAllObjects() 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /VastWords/Sources/Views/WordListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUIX 3 | 4 | /// 单词列表视图 5 | struct WordListView: View { 6 | @EnvironmentObject private var viewModel: WordListViewModel 7 | @State private var hoveredWordId: String? 8 | @FocusState private var isSearchFieldFocused: Bool 9 | 10 | var body: some View { 11 | VStack(spacing: Spacing.none) { 12 | // 搜索框 13 | HStack { 14 | Image(systemName: "magnifyingglass") 15 | .foregroundStyle(.secondary) 16 | .imageScale(.small) 17 | 18 | TextField("搜索 (⌘F)", text: $viewModel.searchText) 19 | .font(Typography.body) 20 | .textFieldStyle(.plain) 21 | .focused($isSearchFieldFocused) 22 | .onSubmit { 23 | // 处理搜索提交 24 | } 25 | 26 | if viewModel.showsClearButton { 27 | Button(action: viewModel.clearSearch) { 28 | Image(systemName: "xmark.circle.fill") 29 | .foregroundStyle(.secondary) 30 | .imageScale(.small) 31 | } 32 | .buttonStyle(.plain) 33 | .keyboardShortcut(.escape, modifiers: []) 34 | } 35 | 36 | // 星标筛选 37 | Toggle(isOn: $viewModel.showStarredOnly) { 38 | Text("星标") 39 | .font(Typography.subtitle) 40 | .foregroundStyle(.secondary) 41 | } 42 | .toggleStyle(.switch) 43 | .controlSize(.mini) 44 | } 45 | .padding(.horizontal, Spacing.extraLarge) 46 | .padding(.vertical, Spacing.medium) 47 | 48 | Divider() 49 | 50 | // 单词列表 51 | ScrollView { 52 | LazyVStack(spacing: Spacing.none) { 53 | ForEach(viewModel.items) { item in 54 | WordRowView( 55 | item: item, 56 | isHovered: hoveredWordId == item.id, 57 | onStarTap: { stars in 58 | viewModel.updateStars(for: item.id, stars: stars) 59 | }, 60 | onDelete: { 61 | viewModel.remove(item.text) 62 | } 63 | ) 64 | .background( 65 | RoundedRectangle(cornerRadius: 0) 66 | .fill(hoveredWordId == item.id ? Color.gray.opacity(0.1) : Color.clear) 67 | ) 68 | .onHover { isHovered in 69 | hoveredWordId = isHovered ? item.id : nil 70 | } 71 | 72 | Divider() 73 | .opacity(0.3) 74 | .padding(.horizontal, Spacing.extraLarge) 75 | } 76 | } 77 | } 78 | .scrollIndicators(.visible) 79 | } 80 | .background { 81 | Button("") { // 隐藏的按钮用于处理快捷键 82 | isSearchFieldFocused = true 83 | } 84 | .keyboardShortcut("f", modifiers: .command) 85 | .opacity(0) 86 | } 87 | .task { 88 | // 初始加载数据 89 | await viewModel.loadWords() 90 | } 91 | } 92 | } 93 | 94 | /// 单词行视图 95 | struct WordRowView: View { 96 | @EnvironmentObject private var viewModel: WordListViewModel 97 | let item: WordListItem 98 | let isHovered: Bool 99 | let onStarTap: (Int) -> Void 100 | let onDelete: () -> Void 101 | 102 | @State private var hoveredStarIndex: Int? 103 | @State private var isWordHovered: Bool = false 104 | @State private var isDefinitionExpanded: Bool = false 105 | 106 | private static let relativeFormatter: RelativeDateTimeFormatter = { 107 | let formatter = RelativeDateTimeFormatter() 108 | formatter.unitsStyle = .short 109 | return formatter 110 | }() 111 | 112 | var body: some View { 113 | VStack(alignment: .leading, spacing: Spacing.small) { 114 | // 第一行:单词、时间、次数 115 | HStack(alignment: .center, spacing: Spacing.small) { 116 | Button { 117 | SystemDictionaryService.shared.lookupInDictionary(item.text) 118 | } label: { 119 | Text(item.text.capitalized) 120 | .font(Typography.title) 121 | .foregroundStyle(.primary) 122 | .underline(isWordHovered) 123 | } 124 | .buttonStyle(.plain) 125 | .onHover { hovered in 126 | isWordHovered = hovered 127 | if hovered { 128 | NSCursor.pointingHand.set() 129 | } else { 130 | NSCursor.arrow.set() 131 | } 132 | } 133 | 134 | Text("•") 135 | .font(.system(size: 9)) 136 | .foregroundStyle(.secondary) 137 | 138 | Text("\(item.count)次") 139 | .font(.system(size: 9)) 140 | .foregroundStyle(.secondary) 141 | .monospacedDigit() 142 | 143 | Text("•") 144 | .font(.system(size: 9)) 145 | .foregroundStyle(.secondary) 146 | 147 | Text(Self.relativeFormatter.localizedString(for: item.updatedAt, relativeTo: Date())) 148 | .font(.system(size: 9)) 149 | .foregroundStyle(.secondary) 150 | 151 | Spacer() 152 | 153 | // 操作按钮 154 | if isHovered { 155 | HStack(spacing: Spacing.medium) { 156 | Button { 157 | NSPasteboard.general.clearContents() 158 | NSPasteboard.general.setString(item.text, forType: .string) 159 | } label: { 160 | Image(systemName: "doc.on.doc") 161 | .imageScale(.small) 162 | .foregroundStyle(.secondary) 163 | } 164 | .buttonStyle(.plain) 165 | .focusable(false) 166 | 167 | Button(action: onDelete) { 168 | Image(systemName: "trash") 169 | .imageScale(.small) 170 | .foregroundStyle(.red) 171 | } 172 | .buttonStyle(.plain) 173 | .focusable(false) 174 | } 175 | } 176 | } 177 | 178 | // 第二行:星级评分 179 | HStack(spacing: Spacing.small) { 180 | // 星级评分 181 | HStack(spacing: Spacing.tiny) { 182 | ForEach(0..<5) { index in 183 | Button { 184 | onStarTap(index + 1) 185 | } label: { 186 | Image(systemName: index <= (hoveredStarIndex ?? (item.stars - 1)) ? "star.fill" : "star") 187 | .foregroundStyle(index < item.stars ? .yellow : .secondary.opacity(0.4)) 188 | .imageScale(.small) 189 | .font(.system(size: 12)) 190 | .onHover { isHovered in 191 | hoveredStarIndex = isHovered ? index : nil 192 | } 193 | .contentShape(Rectangle()) 194 | } 195 | .buttonStyle(.plain) 196 | .focusable(false) 197 | } 198 | } 199 | 200 | Spacer() 201 | } 202 | 203 | // 释义 204 | if viewModel.showDefinition, let definition = item.definition { 205 | Button { 206 | withAnimation(.easeInOut(duration: 0.15)) { 207 | isDefinitionExpanded.toggle() 208 | } 209 | } label: { 210 | Text(definition) 211 | .font(Typography.caption) 212 | .foregroundStyle(.secondary) 213 | .lineLimit(isDefinitionExpanded ? nil : 3) 214 | .multilineTextAlignment(.leading) 215 | } 216 | .buttonStyle(.plain) 217 | .focusable(false) 218 | } 219 | } 220 | .padding(.horizontal, Spacing.extraLarge) 221 | .padding(.vertical, Spacing.medium) 222 | } 223 | } 224 | 225 | #Preview { 226 | WordListView() 227 | .environmentObject(WordListViewModel(repository: .shared)) 228 | } 229 | -------------------------------------------------------------------------------- /VastWords/Sources/Views/StatisticsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Charts 3 | 4 | struct HourlyStatistics: Identifiable { 5 | let id = UUID() 6 | let hour: Date 7 | let count: Int 8 | } 9 | 10 | struct StatisticsView: View { 11 | @EnvironmentObject private var viewModel: WordListViewModel 12 | @State private var selectedHour: Date? 13 | @State private var selectedCount: Int? 14 | 15 | private static let hourFormatter: DateFormatter = { 16 | let formatter = DateFormatter() 17 | formatter.dateFormat = "HH:mm" 18 | return formatter 19 | }() 20 | 21 | private static let dateHourFormatter: DateFormatter = { 22 | let formatter = DateFormatter() 23 | formatter.dateFormat = "MM-dd HH:mm" 24 | return formatter 25 | }() 26 | 27 | private static let dateFormatter: DateFormatter = { 28 | let formatter = DateFormatter() 29 | formatter.dateFormat = "yyyy年MM月dd日" 30 | return formatter 31 | }() 32 | 33 | private var recentStatistics: [HourlyStatistics] { 34 | viewModel.hourlyStatistics.sorted { $0.hour < $1.hour } 35 | } 36 | 37 | private var totalCount: Int { 38 | recentStatistics.reduce(0) { $0 + $1.count } 39 | } 40 | 41 | private var averagePerHour: Double { 42 | guard !recentStatistics.isEmpty else { return 0 } 43 | return Double(totalCount) / Double(max(1, recentStatistics.count)) 44 | } 45 | 46 | private var maxHourlyCount: (hour: Date, count: Int)? { 47 | recentStatistics.max { $0.count < $1.count } 48 | .map { ($0.hour, $0.count) } 49 | } 50 | 51 | var body: some View { 52 | VStack(alignment: .leading, spacing: Spacing.medium) { 53 | // 趋势标题 54 | Text("最近24小时趋势") 55 | .font(.system(size: 9)) 56 | .foregroundStyle(.secondary.opacity(0.8)) 57 | 58 | if recentStatistics.isEmpty { 59 | Text("最近24小时还没有收集单词") 60 | .font(.system(size: 11)) 61 | .foregroundStyle(.secondary) 62 | .frame(height: 80) 63 | .frame(maxWidth: .infinity) 64 | .onAppear { 65 | print("📊 No statistics available for recent 24 hours") 66 | } 67 | } else { 68 | // 图表区域 69 | Chart { 70 | ForEach(recentStatistics) { item in 71 | LineMark( 72 | x: .value("时间", item.hour), 73 | y: .value("数量", item.count) 74 | ) 75 | .foregroundStyle(Color.blue.gradient) 76 | .lineStyle(StrokeStyle(lineWidth: 2)) 77 | .interpolationMethod(.catmullRom) 78 | 79 | AreaMark( 80 | x: .value("时间", item.hour), 81 | y: .value("数量", item.count) 82 | ) 83 | .foregroundStyle(Color.blue.opacity(0.1).gradient) 84 | .interpolationMethod(.catmullRom) 85 | 86 | if selectedHour == item.hour { 87 | PointMark( 88 | x: .value("时间", item.hour), 89 | y: .value("数量", item.count) 90 | ) 91 | .foregroundStyle(.blue) 92 | .symbolSize(100) 93 | } 94 | } 95 | } 96 | .onAppear { 97 | print("📊 Displaying chart with \(recentStatistics.count) data points") 98 | } 99 | .chartXAxis { 100 | AxisMarks(values: .stride(by: .hour, count: 6)) { value in 101 | if let date = value.as(Date.self) { 102 | let calendar = Calendar.current 103 | let hour = calendar.component(.hour, from: date) 104 | 105 | if hour == 0 || hour == 12 { 106 | AxisValueLabel(Self.dateHourFormatter.string(from: date)) 107 | .font(.system(size: 9)) 108 | .foregroundStyle(.secondary) 109 | } else { 110 | AxisValueLabel(Self.hourFormatter.string(from: date)) 111 | .font(.system(size: 9)) 112 | .foregroundStyle(.secondary) 113 | } 114 | 115 | AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) 116 | .foregroundStyle(.secondary.opacity(0.3)) 117 | } 118 | } 119 | } 120 | .chartYAxis { 121 | AxisMarks { value in 122 | AxisValueLabel() 123 | .font(.system(size: 10)) 124 | .foregroundStyle(.secondary) 125 | } 126 | } 127 | .chartYScale(range: .plotDimension(padding: Spacing.medium)) 128 | .chartOverlay { proxy in 129 | GeometryReader { geometry in 130 | Rectangle() 131 | .fill(.clear) 132 | .contentShape(Rectangle()) 133 | .gesture( 134 | DragGesture(minimumDistance: 0) 135 | .onChanged { value in 136 | let currentX = value.location.x 137 | guard currentX >= 0, 138 | currentX <= geometry.size.width, 139 | let hour: Date = proxy.value(atX: currentX) else { 140 | return 141 | } 142 | 143 | if let statistics = recentStatistics.first(where: { abs($0.hour.timeIntervalSince(hour)) < 1800 }) { 144 | selectedHour = statistics.hour 145 | selectedCount = statistics.count 146 | } 147 | } 148 | .onEnded { _ in 149 | selectedHour = nil 150 | selectedCount = nil 151 | } 152 | ) 153 | } 154 | } 155 | .frame(height: 80) 156 | } 157 | 158 | if let hour = selectedHour, 159 | let count = selectedCount { 160 | Text("\(Self.dateHourFormatter.string(from: hour)) 收集了 \(count) 个单词") 161 | .font(.system(size: 11)) 162 | .foregroundStyle(.secondary) 163 | .transition(.opacity) 164 | } 165 | 166 | // 起始时间统计 167 | if let startDate = viewModel.firstWordDate { 168 | HStack(spacing: Spacing.small) { 169 | Image(systemName: "calendar") 170 | .frame(width: 16) 171 | .font(.system(size: 11)) 172 | .foregroundStyle(.secondary) 173 | 174 | Text("开始时间") 175 | .font(.system(size: 11)) 176 | .foregroundStyle(.secondary) 177 | 178 | Spacer() 179 | 180 | Text("\(Self.dateFormatter.string(from: startDate))") 181 | .font(.system(size: 11)) 182 | .foregroundStyle(.secondary) 183 | if let relativeTime = viewModel.relativeTimeDescription { 184 | Text("(\(relativeTime))") 185 | .font(.system(size: 11)) 186 | .foregroundStyle(.secondary.opacity(0.8)) 187 | } 188 | } 189 | } 190 | 191 | // 总计统计 192 | HStack(spacing: Spacing.small) { 193 | Image(systemName: "text.word.spacing") 194 | .frame(width: 16) 195 | .font(.system(size: 11)) 196 | .foregroundStyle(.secondary) 197 | 198 | Text("24小时收集") 199 | .font(.system(size: 11)) 200 | .foregroundStyle(.secondary) 201 | 202 | Spacer() 203 | 204 | Text("\(totalCount) 个单词") 205 | .font(.system(size: 11)) 206 | .foregroundStyle(.secondary) 207 | } 208 | 209 | // 平均统计 210 | HStack(spacing: Spacing.small) { 211 | Image(systemName: "chart.line.uptrend.xyaxis") 212 | .frame(width: 16) 213 | .font(.system(size: 11)) 214 | .foregroundStyle(.secondary) 215 | 216 | Text("24小时平均") 217 | .font(.system(size: 11)) 218 | .foregroundStyle(.secondary) 219 | 220 | Spacer() 221 | 222 | Text(String(format: "每小时 %.1f 个", averagePerHour)) 223 | .font(.system(size: 11)) 224 | .foregroundStyle(.secondary) 225 | } 226 | 227 | // 最高记录 228 | if let max = maxHourlyCount { 229 | HStack(spacing: Spacing.small) { 230 | Image(systemName: "trophy") 231 | .frame(width: 16) 232 | .font(.system(size: 11)) 233 | .foregroundStyle(.secondary) 234 | 235 | Text("24小时最高") 236 | .font(.system(size: 11)) 237 | .foregroundStyle(.secondary) 238 | 239 | Spacer() 240 | 241 | Text("\(Self.dateHourFormatter.string(from: max.hour)) · \(max.count) 个") 242 | .font(.system(size: 11)) 243 | .foregroundStyle(.secondary) 244 | } 245 | } 246 | } 247 | .padding(.horizontal, Spacing.extraLarge) 248 | .padding(.vertical, Spacing.large) 249 | .task { 250 | await viewModel.loadStatistics() 251 | } 252 | } 253 | } 254 | 255 | #Preview { 256 | StatisticsView() 257 | .environmentObject(WordListViewModel(repository: .shared)) 258 | } 259 | -------------------------------------------------------------------------------- /VastWords/Sources/ViewModels/WordListViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import Combine 4 | import Defaults 5 | import ServiceManagement 6 | import SwifterSwift 7 | 8 | extension Defaults.Keys { 9 | static let showStarredOnly = Key("showStarredOnly", default: false) 10 | static let launchAtLogin = Key("launchAtLogin", default: false) 11 | } 12 | 13 | /// 单词列表项 14 | struct WordListItem: Identifiable { 15 | let id: String 16 | let text: String 17 | let count: Int 18 | let stars: Int 19 | let createdAt: Date 20 | let updatedAt: Date 21 | let definition: String? 22 | } 23 | 24 | /// 单词列表视图模型 25 | @MainActor 26 | final class WordListViewModel: ObservableObject { 27 | /// 单词仓库 28 | private let repository: WordRepository 29 | 30 | /// 开机启动的 Helper Bundle ID 31 | private let launchHelperBundleId = "com.vastwords.LaunchHelper" 32 | 33 | /// 单词列表 34 | @Published private(set) var items: [WordListItem] = [] 35 | /// 搜索文本 36 | @Published var searchText: String = "" 37 | /// 是否显示搜索清除按钮 38 | @Published private(set) var showsClearButton = false 39 | /// 是否只显示星标单词 40 | @Published var showStarredOnly = false { 41 | didSet { 42 | Defaults[.showStarredOnly] = showStarredOnly 43 | // 在属性观察器中创建新任务来处理异步操作 44 | Task { 45 | await refreshList() 46 | } 47 | } 48 | } 49 | /// 是否显示释义 50 | @Published var showDefinition = true 51 | /// 是否开机启动 52 | @Published var launchAtLogin = false { 53 | didSet { 54 | // 避免重复触发 55 | guard oldValue != launchAtLogin else { return } 56 | 57 | Task { @MainActor in 58 | do { 59 | try await updateLaunchAtLogin(launchAtLogin) 60 | // 操作成功后才保存状态 61 | Defaults[.launchAtLogin] = launchAtLogin 62 | } catch { 63 | print("⚠️ Failed to \(launchAtLogin ? "enable" : "disable") launch at login: \(error)") 64 | // 如果设置失败,直接设置属性值,避免触发 didSet 65 | self.launchAtLogin = oldValue 66 | } 67 | } 68 | } 69 | } 70 | /// 最近12小时的统计数据 71 | @Published private(set) var hourlyStatistics: [HourlyStatistics] = [] 72 | 73 | /// 单词总数 74 | var totalCount: Int { items.count } 75 | /// 星标单词数量 76 | var starredCount: Int { items.filter { $0.stars > 0 }.count } 77 | /// 所有单词总数(不受筛选影响) 78 | private(set) var allWordsCount: Int = 0 79 | /// 第一个单词的时间 80 | @MainActor 81 | var firstWordDate: Date? { 82 | do { 83 | return try repository.getAll().last?.createdAt 84 | } catch { 85 | print("⚠️ Failed to get first word date: \(error)") 86 | return nil 87 | } 88 | } 89 | 90 | /// 获取相对时间描述 91 | @MainActor 92 | var relativeTimeDescription: String? { 93 | guard let date = firstWordDate else { return nil } 94 | 95 | let formatter = RelativeDateTimeFormatter() 96 | formatter.unitsStyle = .short 97 | 98 | return formatter.localizedString(for: date, relativeTo: Date()) 99 | } 100 | 101 | private var cancellables = Set() 102 | 103 | /// 导出单词列表到文本文件 104 | func exportToTxt(starredOnly: Bool = false) { 105 | Task { @MainActor in 106 | let dateFormatter = DateFormatter() 107 | dateFormatter.dateFormat = "yyyyMMdd_HHmmss" 108 | let timestamp = dateFormatter.string(from: Date()) 109 | 110 | let savePanel = NSSavePanel() 111 | savePanel.allowedContentTypes = [.plainText] 112 | savePanel.nameFieldStringValue = "words\(starredOnly ? "_starred" : "")_\(timestamp).txt" 113 | savePanel.title = "导出\(starredOnly ? "星标" : "")单词列表" 114 | savePanel.message = "选择保存位置" 115 | savePanel.prompt = "导出" 116 | 117 | guard savePanel.runModal() == .OK, 118 | let url = savePanel.url else { 119 | return 120 | } 121 | 122 | do { 123 | // 获取单词并按时间倒序排序 124 | let words = try (starredOnly ? repository.getStarred() : repository.getAll()) 125 | .sorted { $0.updatedAt > $1.updatedAt } 126 | .map { $0.text } 127 | .joined(separator: "\n") 128 | 129 | try words.write(to: url, atomically: true, encoding: .utf8) 130 | } catch { 131 | print("⚠️ Failed to export words: \(error)") 132 | } 133 | } 134 | } 135 | 136 | init(repository: WordRepository) { 137 | self.repository = repository 138 | 139 | // 从系统获取实际的开机启动状态 140 | let isEnabled = SMAppService.mainApp.status == .enabled 141 | self.launchAtLogin = isEnabled 142 | Defaults[.launchAtLogin] = isEnabled 143 | 144 | // 从 Defaults 读取星标筛选状态 145 | self.showStarredOnly = Defaults[.showStarredOnly] 146 | 147 | setupBindings() 148 | } 149 | 150 | private func setupBindings() { 151 | // 监听搜索文本变化 152 | $searchText 153 | .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) 154 | .removeDuplicates() 155 | .sink { [weak self] query in 156 | // 在闭包中创建新任务来处理异步操作 157 | guard let self = self else { return } 158 | Task { 159 | await self.refreshList() 160 | } 161 | } 162 | .store(in: &cancellables) 163 | 164 | // 监听是否显示清除按钮 165 | $searchText 166 | .map { !$0.isEmpty } 167 | .assign(to: \.showsClearButton, on: self) 168 | .store(in: &cancellables) 169 | } 170 | 171 | /// 刷新列表,考虑搜索和星标状态 172 | private func refreshList() async { 173 | if searchText.isEmpty { 174 | if showStarredOnly { 175 | await loadStarredWords() 176 | } else { 177 | await loadWords() 178 | } 179 | } else { 180 | await search(searchText) 181 | } 182 | } 183 | 184 | /// 加载最近12小时的统计数据 185 | func loadStatistics() async { 186 | do { 187 | let now = Date() 188 | let calendar = Calendar.current 189 | 190 | // 创建最近24小时的时间点 191 | let hours = (0...23).map { hourOffset in 192 | calendar.date(byAdding: .hour, value: -hourOffset, to: now)! 193 | }.reversed() 194 | 195 | // 获取每个小时的单词数量 196 | hourlyStatistics = try await withThrowingTaskGroup(of: HourlyStatistics.self) { group in 197 | for hour in hours { 198 | group.addTask { @MainActor in 199 | let startOfHour = calendar.startOfHour(for: hour) 200 | let endOfHour = calendar.date(byAdding: .hour, value: 1, to: startOfHour)! 201 | let count = try self.repository.getWordCount(from: startOfHour, to: endOfHour) 202 | return HourlyStatistics(hour: startOfHour, count: count) 203 | } 204 | } 205 | 206 | var statistics: [HourlyStatistics] = [] 207 | for try await stat in group { 208 | statistics.append(stat) 209 | } 210 | return statistics.sorted { $0.hour < $1.hour } 211 | } 212 | 213 | print("📊 Total statistics loaded: \(hourlyStatistics.count) hours") 214 | } catch { 215 | print("⚠️ Failed to load statistics: \(error)") 216 | hourlyStatistics = [] 217 | } 218 | } 219 | 220 | /// 加载所有单词 221 | func loadWords() async { 222 | do { 223 | // 获取数据 224 | let words = try repository.getAll() 225 | 226 | // 并发处理定义查询 227 | items = try await words.concurrentMap { word in 228 | let definition = await SystemDictionaryService.shared.lookup(word.text) 229 | return word.toListItem(definition: definition) 230 | } 231 | } catch { 232 | print("⚠️ Failed to load words: \(error)") 233 | } 234 | } 235 | 236 | /// 加载星标单词 237 | private func loadStarredWords() async { 238 | do { 239 | // 获取数据 240 | let words = try repository.getStarred() 241 | 242 | items = try await words.concurrentMap { word in 243 | let definition = await SystemDictionaryService.shared.lookup(word.text) 244 | return word.toListItem(definition: definition) 245 | } 246 | } catch { 247 | print("⚠️ Failed to load starred words: \(error)") 248 | } 249 | } 250 | 251 | /// 更新单词星级 252 | func updateStars(for wordId: String, stars: Int) { 253 | Task { @MainActor in 254 | do { 255 | try repository.updateStars(for: wordId, stars: stars) 256 | await refreshList() 257 | await loadStatistics() 258 | } catch { 259 | print("⚠️ Failed to update stars: \(error)") 260 | } 261 | } 262 | } 263 | 264 | /// 删除单词 265 | func remove(_ word: String) { 266 | Task { @MainActor in 267 | do { 268 | try repository.remove(word) 269 | await refreshList() 270 | await loadStatistics() 271 | } catch { 272 | print("⚠️ Failed to remove word: \(error)") 273 | } 274 | } 275 | } 276 | 277 | /// 清空所有单词 278 | func removeAll() { 279 | Task { @MainActor in 280 | do { 281 | try repository.removeAll() 282 | showStarredOnly = false 283 | await loadWords() 284 | await loadStatistics() 285 | } catch { 286 | print("⚠️ Failed to remove all words: \(error)") 287 | } 288 | } 289 | } 290 | 291 | /// 搜索单词 292 | private func search(_ query: String) async { 293 | do { 294 | // 获取和过滤数据 295 | var words = try repository.search(query) 296 | if self.showStarredOnly { 297 | words = words.filter { $0.stars > 0 } 298 | } 299 | 300 | items = try await words.concurrentMap { word in 301 | let definition = await SystemDictionaryService.shared.lookup(word.text) 302 | return word.toListItem(definition: definition) 303 | } 304 | } catch { 305 | print("⚠️ Failed to search words: \(error)") 306 | } 307 | } 308 | 309 | /// 清除搜索文本 310 | func clearSearch() { 311 | searchText = "" 312 | } 313 | 314 | /// 更新开机启动状态 315 | private func updateLaunchAtLogin(_ enabled: Bool) async throws { 316 | if enabled { 317 | if SMAppService.mainApp.status == .enabled { 318 | return // 已经启用,不需要重复操作 319 | } 320 | try SMAppService.mainApp.register() 321 | } else { 322 | if SMAppService.mainApp.status == .notRegistered { 323 | return // 已经禁用,不需要重复操作 324 | } 325 | try await SMAppService.mainApp.unregister() 326 | } 327 | } 328 | } 329 | 330 | private extension Calendar { 331 | func startOfHour(for date: Date) -> Date { 332 | let components = dateComponents([.year, .month, .day, .hour], from: date) 333 | return self.date(from: components) ?? date 334 | } 335 | } 336 | 337 | // 添加 Word 的扩展方法 338 | private extension Word { 339 | func toListItem(definition: String?) -> WordListItem { 340 | WordListItem( 341 | id: text, 342 | text: text, 343 | count: count, 344 | stars: stars, 345 | createdAt: createdAt, 346 | updatedAt: updatedAt, 347 | definition: definition 348 | ) 349 | } 350 | } 351 | 352 | // 添加数组的异步映射扩展 353 | private extension Array { 354 | func concurrentMap(_ transform: @escaping (Element) async throws -> T) async throws -> [T] { 355 | try await withThrowingTaskGroup(of: (Int, T).self) { group in 356 | // 添加所有任务到组 357 | for (index, element) in enumerated() { 358 | group.addTask { 359 | let result = try await transform(element) 360 | return (index, result) 361 | } 362 | } 363 | 364 | // 收集结果并保持顺序 365 | var results = [(Int, T)]() 366 | for try await result in group { 367 | results.append(result) 368 | } 369 | 370 | return results 371 | .sorted { $0.0 < $1.0 } 372 | .map { $0.1 } 373 | } 374 | } 375 | } 376 | --------------------------------------------------------------------------------