diff --git a/README.md b/README.md
index 8cb69c7..8d1e95c 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
TextGrabber2 is a free and **open-source** macOS menu bar app that efficiently detects text from copied images. This eliminates the need to save images as files and then delete them solely for the purpose of text detection.
-
+
For example, press `Control-Shift-Command-4` to capture a portion of the screen and then open TextGrabber2 from the menu bar.
@@ -30,7 +30,7 @@ TextGrabber2 is NOT a screenshot tool, meaning it doesn't require access like `S
TextGrabber2 utilizes the built-in [Vision](https://developer.apple.com/documentation/vision/) framework, which is on-device, secure, fast, accurate, and **free**. In fact, it's often superior to many paid services.
-TextGrabber2 automatically extracts data types like numbers and links, making copying super easy.
+TextGrabber2 connects to [system services](https://github.com/TextGrabber2-app/TextGrabber2/wiki#connect-to-system-services), you can easily integrate your workflows.
TextGrabber2 does NOT have any settings; it works magically until something goes wrong.
diff --git a/Screenshots/01.png b/Screenshots/01.png
index 8589ab4..83449ed 100644
Binary files a/Screenshots/01.png and b/Screenshots/01.png differ
diff --git a/Screenshots/02.png b/Screenshots/02.png
index b1a8bc1..f0d2e30 100644
Binary files a/Screenshots/02.png and b/Screenshots/02.png differ
diff --git a/TextGrabber2.xcodeproj/project.pbxproj b/TextGrabber2.xcodeproj/project.pbxproj
index c767f02..da1c9b1 100644
--- a/TextGrabber2.xcodeproj/project.pbxproj
+++ b/TextGrabber2.xcodeproj/project.pbxproj
@@ -7,6 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
+ 8737A24A2BBEC27700042E83 /* Services in Resources */ = {isa = PBXBuildFile; fileRef = 8737A2492BBEC27700042E83 /* Services */; };
+ 8737A24C2BBECB8800042E83 /* Services.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8737A24B2BBECB8800042E83 /* Services.swift */; };
8772FAE92BAC743D00DBEAA0 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 8772FAE82BAC743D00DBEAA0 /* Localizable.xcstrings */; };
87E192C32BAADE1200A87A4E /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87E192C22BAADE1200A87A4E /* App.swift */; };
87E192C72BAADE1300A87A4E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 87E192C62BAADE1300A87A4E /* Assets.xcassets */; };
@@ -28,6 +30,8 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
+ 8737A2492BBEC27700042E83 /* Services */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Services; sourceTree = ""; };
+ 8737A24B2BBECB8800042E83 /* Services.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services.swift; sourceTree = ""; };
8772FAE82BAC743D00DBEAA0 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
87E192BF2BAADE1200A87A4E /* TextGrabber2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TextGrabber2.app; sourceTree = BUILT_PRODUCTS_DIR; };
87E192C22BAADE1200A87A4E /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; };
@@ -65,6 +69,7 @@
8772FAE72BAC73F100DBEAA0 /* Resources */ = {
isa = PBXGroup;
children = (
+ 8737A2492BBEC27700042E83 /* Services */,
87E192C62BAADE1300A87A4E /* Assets.xcassets */,
8772FAE82BAC743D00DBEAA0 /* Localizable.xcstrings */,
);
@@ -133,6 +138,7 @@
87E192F62BABD18900A87A4E /* Logger.swift */,
87E192F02BABB6EA00A87A4E /* Recognizer.swift */,
87E192E82BAAFF0200A87A4E /* Resources.swift */,
+ 8737A24B2BBECB8800042E83 /* Services.swift */,
);
path = Sources;
sourceTree = "";
@@ -197,6 +203,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 8737A24A2BBEC27700042E83 /* Services in Resources */,
8772FAE92BAC743D00DBEAA0 /* Localizable.xcstrings in Resources */,
87E192C72BAADE1300A87A4E /* Assets.xcassets in Resources */,
);
@@ -209,6 +216,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 8737A24C2BBECB8800042E83 /* Services.swift in Sources */,
87E192DF2BAAE38700A87A4E /* NSControl+Extension.swift in Sources */,
87E192F32BABB71D00A87A4E /* NSPasteboard+Extension.swift in Sources */,
87E192E72BAAFBC900A87A4E /* SMAppService+Extension.swift in Sources */,
diff --git a/TextGrabber2/Resources/Localizable.xcstrings b/TextGrabber2/Resources/Localizable.xcstrings
index acb1d4c..45ba7dd 100644
--- a/TextGrabber2/Resources/Localizable.xcstrings
+++ b/TextGrabber2/Resources/Localizable.xcstrings
@@ -69,6 +69,23 @@
}
}
},
+ "Configure" : {
+ "comment" : "[Menu] Configure system services",
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "配置"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "配置"
+ }
+ }
+ }
+ },
"Copy All" : {
"comment" : "[Menu] Copy all text at once",
"localizations" : {
@@ -86,6 +103,40 @@
}
}
},
+ "Documentation" : {
+ "comment" : "[Menu] Open the wiki for system services",
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "文档"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "文檔"
+ }
+ }
+ }
+ },
+ "en-US" : {
+ "comment" : "Identifier used to locate localized resources",
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "zh-Hans"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "zh-Hant"
+ }
+ }
+ }
+ },
"GitHub" : {
"comment" : "[Menu] Open the TextGrabber2 repository on GitHub",
"localizations" : {
@@ -222,6 +273,23 @@
}
}
},
+ "Services" : {
+ "comment" : "[Menu] System services menu",
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "服务"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "服務"
+ }
+ }
+ }
+ },
"Version" : {
"comment" : "[Menu] Version number label",
"localizations" : {
diff --git a/TextGrabber2/Resources/Services/en-US.json b/TextGrabber2/Resources/Services/en-US.json
new file mode 100644
index 0000000..de09f89
--- /dev/null
+++ b/TextGrabber2/Resources/Services/en-US.json
@@ -0,0 +1,34 @@
+[
+ {
+ "serviceName": "Look Up in Dictionary",
+ "displayName": "Look Up in Dictionary"
+ },
+ {
+ "serviceName": "Search With %WebSearchProvider@",
+ "displayName": "Search in Safari"
+ },
+ {
+ "serviceName": "SEARCH_WITH_SPOTLIGHT",
+ "displayName": "Search with Spotlight"
+ },
+ {
+ "serviceName": "Convert Text from Simplified to Traditional Chinese",
+ "displayName": "Convert to Traditional Chinese"
+ },
+ {
+ "serviceName": "Convert Text from Traditional to Simplified Chinese",
+ "displayName": "Convert to Simplified Chinese"
+ },
+ {
+ "serviceName": "New TextEdit Window Containing Selection",
+ "displayName": "New File in TextEdit"
+ },
+ {
+ "serviceName": "Make Sticky",
+ "displayName": "Make New Sticky Note"
+ },
+ {
+ "serviceName": "Summarize",
+ "displayName": "Summarize"
+ }
+]
\ No newline at end of file
diff --git a/TextGrabber2/Resources/Services/zh-Hans.json b/TextGrabber2/Resources/Services/zh-Hans.json
new file mode 100644
index 0000000..a60dbd7
--- /dev/null
+++ b/TextGrabber2/Resources/Services/zh-Hans.json
@@ -0,0 +1,34 @@
+[
+ {
+ "serviceName": "Look Up in Dictionary",
+ "displayName": "在词典中查询"
+ },
+ {
+ "serviceName": "Search With %WebSearchProvider@",
+ "displayName": "在 Safari 中搜索"
+ },
+ {
+ "serviceName": "SEARCH_WITH_SPOTLIGHT",
+ "displayName": "用 Spotlight 搜索"
+ },
+ {
+ "serviceName": "Convert Text from Simplified to Traditional Chinese",
+ "displayName": "转换为繁体中文"
+ },
+ {
+ "serviceName": "Convert Text from Traditional to Simplified Chinese",
+ "displayName": "转换为简体中文"
+ },
+ {
+ "serviceName": "New TextEdit Window Containing Selection",
+ "displayName": "在 TextEdit 新建文件"
+ },
+ {
+ "serviceName": "Make Sticky",
+ "displayName": "创建新便笺条"
+ },
+ {
+ "serviceName": "Summarize",
+ "displayName": "摘要"
+ }
+]
\ No newline at end of file
diff --git a/TextGrabber2/Resources/Services/zh-Hant.json b/TextGrabber2/Resources/Services/zh-Hant.json
new file mode 100644
index 0000000..2ea49e3
--- /dev/null
+++ b/TextGrabber2/Resources/Services/zh-Hant.json
@@ -0,0 +1,34 @@
+[
+ {
+ "serviceName": "Look Up in Dictionary",
+ "displayName": "在辭典中查詢"
+ },
+ {
+ "serviceName": "Search With %WebSearchProvider@",
+ "displayName": "在 Safari 中搜尋"
+ },
+ {
+ "serviceName": "SEARCH_WITH_SPOTLIGHT",
+ "displayName": "用 Spotlight 搜尋"
+ },
+ {
+ "serviceName": "Convert Text from Simplified to Traditional Chinese",
+ "displayName": "轉換為繁體中文"
+ },
+ {
+ "serviceName": "Convert Text from Traditional to Simplified Chinese",
+ "displayName": "轉換為簡體中文"
+ },
+ {
+ "serviceName": "New TextEdit Window Containing Selection",
+ "displayName": "在 TextEdit 新建檔案"
+ },
+ {
+ "serviceName": "Make Sticky",
+ "displayName": "建立新便條紙"
+ },
+ {
+ "serviceName": "Summarize",
+ "displayName": "摘要"
+ }
+]
\ No newline at end of file
diff --git a/TextGrabber2/Sources/App.swift b/TextGrabber2/Sources/App.swift
index d43527f..dd4ccf1 100644
--- a/TextGrabber2/Sources/App.swift
+++ b/TextGrabber2/Sources/App.swift
@@ -26,6 +26,7 @@ final class App: NSObject, NSApplicationDelegate {
menu.addItem(howToItem)
menu.addItem(.separator())
menu.addItem(copyAllItem)
+ menu.addItem(servicesItem)
menu.addItem(clipboardItem)
menu.addItem(.separator())
menu.addItem(launchAtLoginItem)
@@ -82,6 +83,23 @@ final class App: NSObject, NSApplicationDelegate {
return item
}()
+ private let servicesItem: NSMenuItem = {
+ let item = NSMenuItem(title: Localized.menuTitleServices)
+ let menu = NSMenu()
+ menu.addItem(.separator())
+
+ menu.addItem(withTitle: Localized.menuTitleConfigure) {
+ NSWorkspace.shared.open(Services.fileURL)
+ }
+
+ menu.addItem(withTitle: Localized.menuTitleDocumentation) {
+ NSWorkspace.shared.safelyOpenURL(string: "\(Links.github)/wiki#connect-to-system-services")
+ }
+
+ item.submenu = menu
+ return item
+ }()
+
private let clipboardItem: NSMenuItem = {
let item = NSMenuItem(title: Localized.menuTitleClipboard)
let menu = NSMenu()
@@ -118,6 +136,7 @@ final class App: NSObject, NSApplicationDelegate {
extension App {
func applicationDidFinishLaunching(_ notification: Notification) {
+ Services.initialize()
clearMenuItems()
statusItem.isVisible = true
}
@@ -128,6 +147,18 @@ extension App {
extension App: NSMenuDelegate {
func menuWillOpen(_ menu: NSMenu) {
startDetection()
+
+ // Update the services menu
+ servicesItem.submenu?.removeItems { $0 is ServiceItem }
+ for service in Services.items.reversed() {
+ let item = ServiceItem(title: service.displayName)
+ item.addAction {
+ NSPasteboard.general.string = self.currentResult?.spacesJoined
+ NSPerformService(service.serviceName, .general)
+ }
+
+ servicesItem.submenu?.insertItem(item, at: 0)
+ }
// For an edge case, we can capture the screen while the menu is shown.
let timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
@@ -153,9 +184,8 @@ extension App: NSMenuDelegate {
// MARK: - Private
private extension App {
- class ResultItem: NSMenuItem {
- /* Just a sub-class to be identifiable */
- }
+ class ResultItem: NSMenuItem { /* Just a sub-class to be identifiable */ }
+ class ServiceItem: NSMenuItem { /* Just a sub-class to be identifiable */ }
func clearMenuItems() {
hintItem.title = Localized.menuTitleHintCapture
diff --git a/TextGrabber2/Sources/Resources.swift b/TextGrabber2/Sources/Resources.swift
index 8dc0770..be02cf4 100644
--- a/TextGrabber2/Sources/Resources.swift
+++ b/TextGrabber2/Sources/Resources.swift
@@ -14,6 +14,7 @@ import Foundation
https://developer.apple.com/documentation/xcode/localizing-and-varying-text-with-a-string-catalog
*/
enum Localized {
+ static let languageIdentifier = String(localized: "en-US", comment: "Identifier used to locate localized resources")
static let menuTitleHintCapture = String(localized: "Capture Screen to Detect", comment: "[Menu] Hint for capturing the screen")
static let menuTitleHintCopy = String(localized: "Click to Copy", comment: "[Menu] Hint for copying text")
static let menuTitleHintRecognizing = String(localized: "Recognizing...", comment: "[Menu] The recognition is ongoing")
@@ -21,6 +22,9 @@ enum Localized {
static let menuTitleCopyAll = String(localized: "Copy All", comment: "[Menu] Copy all text at once")
static let menuTitleJoinWithLineBreaks = String(localized: "Join with Line Breaks", comment: "[Menu] Join all text with line breaks and copy them")
static let menuTitleJoinWithSpaces = String(localized: "Join with Spaces", comment: "[Menu] Join all text with spaces and copy them")
+ static let menuTitleServices = String(localized: "Services", comment: "[Menu] System services menu")
+ static let menuTitleConfigure = String(localized: "Configure", comment: "[Menu] Configure system services")
+ static let menuTitleDocumentation = String(localized: "Documentation", comment: "[Menu] Open the wiki for system services")
static let menuTitleClipboard = String(localized: "Clipboard", comment: "[Menu] Clipboard options")
static let menuTitleSaveAsFile = String(localized: "Save as File", comment: "[Menu] Save the clipboard as file")
static let menuTitleClearContents = String(localized: "Clear Contents", comment: "[Menu] Clear the clipboard")
diff --git a/TextGrabber2/Sources/Services.swift b/TextGrabber2/Sources/Services.swift
new file mode 100644
index 0000000..c9ebdaf
--- /dev/null
+++ b/TextGrabber2/Sources/Services.swift
@@ -0,0 +1,65 @@
+//
+// Services.swift
+// TextGrabber2
+//
+// Created by cyan on 2024/4/4.
+//
+
+import AppKit
+
+/**
+ https://support.apple.com/guide/mac-help/mchlp1012/mac.
+
+ Definition file is located at ~/Library/Containers/app.cyan.textgrabber2/Data/Documents/
+ */
+enum Services {
+ struct Item: Decodable {
+ let serviceName: String
+ let displayName: String
+ }
+
+ static var fileURL: URL {
+ URL.documentsDirectory.appending(
+ path: Constants.fileName,
+ directoryHint: .notDirectory
+ )
+ }
+
+ static var items: [Item] {
+ guard let data = try? Data(contentsOf: fileURL) else {
+ Logger.log(.error, "Missing \(Constants.fileName)")
+ return []
+ }
+
+ guard let items = try? JSONDecoder().decode([Item].self, from: data) else {
+ Logger.log(.error, "Failed to decode the file")
+ return []
+ }
+
+ return items
+ }
+
+ static func initialize() {
+ guard !FileManager.default.fileExists(atPath: fileURL.path()) else {
+ return Logger.log(.info, "\(Constants.fileName) was created before")
+ }
+
+ guard let sourceURL = Bundle.main.url(forResource: "Services/\(Localized.languageIdentifier)", withExtension: "json") else {
+ return Logger.assertFail("Missing source file to copy from")
+ }
+
+ do {
+ try FileManager.default.copyItem(at: sourceURL, to: fileURL)
+ } catch {
+ Logger.log(.error, "\(error)")
+ }
+ }
+}
+
+// MARK: - Private
+
+private extension Services {
+ enum Constants {
+ static let fileName = "services.json"
+ }
+}