diff --git a/McBopomofo.xcodeproj/project.pbxproj b/McBopomofo.xcodeproj/project.pbxproj index 8a7ab31c..cee0bd25 100644 --- a/McBopomofo.xcodeproj/project.pbxproj +++ b/McBopomofo.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -44,6 +44,8 @@ 6AE210B315FC63CC003659FE /* PlainBopomofo@2x.tiff in Resources */ = {isa = PBXBuildFile; fileRef = 6AE210B115FC63CC003659FE /* PlainBopomofo@2x.tiff */; }; 6AFF97F2253B299E007F1C49 /* NonModalAlertWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 6AFF97F0253B299E007F1C49 /* NonModalAlertWindowController.xib */; }; B058C5272AC9DF51002EDD66 /* ServiceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B058C5262AC9DF51002EDD66 /* ServiceProvider.swift */; }; + B058C5292AC9DF51002EDD66 /* McBopomofoService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B058C5282AC9DF51002EDD66 /* McBopomofoService.swift */; }; + B058C52F2AC9DF51002EDD66 /* CustomUrlHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B058C52E2AC9DF51002EDD66 /* CustomUrlHandler.swift */; }; B0781B352ACA2655003D9F75 /* ServicesMenu.strings in Resources */ = {isa = PBXBuildFile; fileRef = B0781B372ACA2655003D9F75 /* ServicesMenu.strings */; }; D40EFFA22EB669F900C1728A /* InfoCollector in Frameworks */ = {isa = PBXBuildFile; productRef = D40EFFA12EB669F900C1728A /* InfoCollector */; }; D41355D8278D74B5005E5CBD /* LanguageModelManager.mm in Sources */ = {isa = PBXBuildFile; fileRef = D41355D7278D7409005E5CBD /* LanguageModelManager.mm */; }; @@ -66,6 +68,7 @@ D4451AC92E688C6B00E8F5AB /* SystemCharacterInfo in Frameworks */ = {isa = PBXBuildFile; productRef = D4451AC82E688C6B00E8F5AB /* SystemCharacterInfo */; }; D449AD5F2B393C00000C5812 /* InputMacroTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D449AD5E2B393C00000C5812 /* InputMacroTests.swift */; }; D449AD612B39506D000C5812 /* ServiceProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D449AD602B39506D000C5812 /* ServiceProviderTests.swift */; }; + D44B6DA32F94D6B400EEA7DF /* McBopomofoShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D44B6D982F94D6B400EEA7DF /* McBopomofoShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D44FB74527915565003C80A6 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44FB74427915555003C80A6 /* Preferences.swift */; }; D44FB74D2792189A003C80A6 /* PhraseReplacementMap.cpp in Sources */ = {isa = PBXBuildFile; fileRef = D44FB74B2792189A003C80A6 /* PhraseReplacementMap.cpp */; }; D456576E279E4F7B00DF6BC9 /* KeyHandlerInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = D456576D279E4F7B00DF6BC9 /* KeyHandlerInput.swift */; }; @@ -117,6 +120,13 @@ remoteGlobalIDString = 6A0D4EA115FC0D2D00ABF4B3; remoteInfo = McBopomofo; }; + D44B6DA02F94D6B400EEA7DF /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 6A0D4E9415FC0CFA00ABF4B3 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D44B6D972F94D6B400EEA7DF; + remoteInfo = McBopomofoShare; + }; D485D3BA2796A8A000657FF3 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6A0D4E9415FC0CFA00ABF4B3 /* Project object */; @@ -126,6 +136,20 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + D44B6DA22F94D6B400EEA7DF /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + D44B6DA32F94D6B400EEA7DF /* McBopomofoShare.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 6A0D4EA215FC0D2D00ABF4B3 /* McBopomofo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = McBopomofo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6A0D4EEF15FC0DA600ABF4B3 /* Bopomofo.tiff */ = {isa = PBXFileReference; lastKnownFileType = image.tiff; path = Bopomofo.tiff; sourceTree = ""; }; @@ -189,6 +213,8 @@ 6AE210B115FC63CC003659FE /* PlainBopomofo@2x.tiff */ = {isa = PBXFileReference; lastKnownFileType = image.tiff; path = "PlainBopomofo@2x.tiff"; sourceTree = ""; }; 6AFF97F0253B299E007F1C49 /* NonModalAlertWindowController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NonModalAlertWindowController.xib; sourceTree = ""; }; B058C5262AC9DF51002EDD66 /* ServiceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProvider.swift; sourceTree = ""; }; + B058C5282AC9DF51002EDD66 /* McBopomofoService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = McBopomofoService.swift; sourceTree = ""; }; + B058C52E2AC9DF51002EDD66 /* CustomUrlHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomUrlHandler.swift; sourceTree = ""; }; B0781B362ACA2655003D9F75 /* en */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/ServicesMenu.strings; sourceTree = ""; }; B0781B382ACA2659003D9F75 /* zh-Hant */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/ServicesMenu.strings"; sourceTree = ""; }; D40EFFA02EB669BB00C1728A /* InfoCollector */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = InfoCollector; path = Packages/InfoCollector; sourceTree = ""; }; @@ -215,6 +241,7 @@ D43FC40A2B23788400ED5A1C /* InputMacro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMacro.swift; sourceTree = ""; }; D449AD5E2B393C00000C5812 /* InputMacroTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMacroTests.swift; sourceTree = ""; }; D449AD602B39506D000C5812 /* ServiceProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProviderTests.swift; sourceTree = ""; }; + D44B6D982F94D6B400EEA7DF /* McBopomofoShare.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = McBopomofoShare.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D44FB74427915555003C80A6 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; D44FB74B2792189A003C80A6 /* PhraseReplacementMap.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = PhraseReplacementMap.cpp; sourceTree = ""; }; D44FB74C2792189A003C80A6 /* PhraseReplacementMap.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PhraseReplacementMap.h; sourceTree = ""; }; @@ -261,6 +288,20 @@ D4F0BBEB279B14D00071253C /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; name = "zh-Hant"; path = "zh-Hant.lproj/Credits.rtf"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + D44B6DA62F94D6B400EEA7DF /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = D44B6D972F94D6B400EEA7DF /* McBopomofoShare */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + D44B6D992F94D6B400EEA7DF /* McBopomofoShare */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (D44B6DA62F94D6B400EEA7DF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = McBopomofoShare; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 6A0D4E9F15FC0D2D00ABF4B3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -289,6 +330,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D44B6D952F94D6B400EEA7DF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D485D3B32796A8A000657FF3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -305,6 +353,7 @@ D427F766278C9CBD004A2160 /* Packages */, 6A0D4EC215FC0D3C00ABF4B3 /* Source */, D485D3B72796A8A000657FF3 /* McBopomofoTests */, + D44B6D992F94D6B400EEA7DF /* McBopomofoShare */, 6A0D4EA315FC0D2D00ABF4B3 /* Products */, D47D73C127A7200500255A50 /* Frameworks */, ); @@ -316,6 +365,7 @@ 6A0D4EA215FC0D2D00ABF4B3 /* McBopomofo.app */, 6ACA41CB15FC1D7500935EF6 /* McBopomofoInstaller.app */, D485D3B62796A8A000657FF3 /* McBopomofoTests.xctest */, + D44B6D982F94D6B400EEA7DF /* McBopomofoShare.appex */, ); name = Products; sourceTree = ""; @@ -347,6 +397,8 @@ D41B626D2B87B5C100583148 /* ServiceProviderInputHelper.h */, D41B626E2B87B5C100583148 /* ServiceProviderInputHelper.mm */, B058C5262AC9DF51002EDD66 /* ServiceProvider.swift */, + B058C5282AC9DF51002EDD66 /* McBopomofoService.swift */, + B058C52E2AC9DF51002EDD66 /* CustomUrlHandler.swift */, D47B92BF27972AC800458394 /* main.swift */, 6A0D4EF615FC0DA600ABF4B3 /* McBopomofo-Prefix.pch */, D427A9BF25ED28CC005D43E0 /* McBopomofo-Bridging-Header.h */, @@ -549,11 +601,13 @@ 6A0D4E9E15FC0D2D00ABF4B3 /* Sources */, 6A0D4E9F15FC0D2D00ABF4B3 /* Frameworks */, 6A0D4EA015FC0D2D00ABF4B3 /* Resources */, + D44B6DA22F94D6B400EEA7DF /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( 6A38BC2615FC131100A8A51F /* PBXTargetDependency */, + D44B6DA12F94D6B400EEA7DF /* PBXTargetDependency */, ); name = McBopomofo; packageProductDependencies = ( @@ -596,6 +650,28 @@ productReference = 6ACA41CB15FC1D7500935EF6 /* McBopomofoInstaller.app */; productType = "com.apple.product-type.application"; }; + D44B6D972F94D6B400EEA7DF /* McBopomofoShare */ = { + isa = PBXNativeTarget; + buildConfigurationList = D44B6DA72F94D6B400EEA7DF /* Build configuration list for PBXNativeTarget "McBopomofoShare" */; + buildPhases = ( + D44B6D942F94D6B400EEA7DF /* Sources */, + D44B6D952F94D6B400EEA7DF /* Frameworks */, + D44B6D962F94D6B400EEA7DF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + D44B6D992F94D6B400EEA7DF /* McBopomofoShare */, + ); + name = McBopomofoShare; + packageProductDependencies = ( + ); + productName = McBopomofoShare; + productReference = D44B6D982F94D6B400EEA7DF /* McBopomofoShare.appex */; + productType = "com.apple.product-type.app-extension"; + }; D485D3B52796A8A000657FF3 /* McBopomofoTests */ = { isa = PBXNativeTarget; buildConfigurationList = D485D3BE2796A8A000657FF3 /* Build configuration list for PBXNativeTarget "McBopomofoTests" */; @@ -621,7 +697,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1320; + LastSwiftUpdateCheck = 2640; LastUpgradeCheck = 1510; TargetAttributes = { 6A0D4EA115FC0D2D00ABF4B3 = { @@ -630,6 +706,9 @@ 6ACA41CA15FC1D7500935EF6 = { LastSwiftMigration = 1320; }; + D44B6D972F94D6B400EEA7DF = { + CreatedOnToolsVersion = 26.4; + }; D485D3B52796A8A000657FF3 = { CreatedOnToolsVersion = 13.2.1; LastSwiftMigration = 1500; @@ -657,6 +736,7 @@ 6ACA41CA15FC1D7500935EF6 /* McBopomofoInstaller */, 6A38BC2115FC12FD00A8A51F /* Data */, D485D3B52796A8A000657FF3 /* McBopomofoTests */, + D44B6D972F94D6B400EEA7DF /* McBopomofoShare */, ); }; /* End PBXProject section */ @@ -708,6 +788,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D44B6D962F94D6B400EEA7DF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D485D3B42796A8A000657FF3 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -776,6 +863,8 @@ D43FC40B2B23788400ED5A1C /* InputMacro.swift in Sources */, 6ADF5B1A2BA513E000577D98 /* MemoryMappedFile.cpp in Sources */, B058C5272AC9DF51002EDD66 /* ServiceProvider.swift in Sources */, + B058C5292AC9DF51002EDD66 /* McBopomofoService.swift in Sources */, + B058C52F2AC9DF51002EDD66 /* CustomUrlHandler.swift in Sources */, 6ADF5B192BA513E000577D98 /* AssociatedPhrasesV2.cpp in Sources */, 6ADF5B1B2BA513E000577D98 /* UTF8Helper.cpp in Sources */, D41355D8278D74B5005E5CBD /* LanguageModelManager.mm in Sources */, @@ -792,6 +881,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D44B6D942F94D6B400EEA7DF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D485D3B22796A8A000657FF3 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -821,6 +917,11 @@ target = 6A0D4EA115FC0D2D00ABF4B3 /* McBopomofo */; targetProxy = 6ACA420015FC1DCC00935EF6 /* PBXContainerItemProxy */; }; + D44B6DA12F94D6B400EEA7DF /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D44B6D972F94D6B400EEA7DF /* McBopomofoShare */; + targetProxy = D44B6DA02F94D6B400EEA7DF /* PBXContainerItemProxy */; + }; D485D3BB2796A8A000657FF3 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 6A0D4EA115FC0D2D00ABF4B3 /* McBopomofo */; @@ -1328,6 +1429,123 @@ }; name = Release; }; + D44B6DA42F94D6B400EEA7DF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = McBopomofoShare/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Add to McBopomofo"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 11.5; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.openvanilla.inputmethod.McBopomofo.McBopomofoShare; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = macosx; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + D44B6DA52F94D6B400EEA7DF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = McBopomofoShare/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Add to McBopomofo"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 11.5; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.openvanilla.inputmethod.McBopomofo.McBopomofoShare; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = macosx; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; D485D3BC2796A8A000657FF3 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1465,6 +1683,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D44B6DA72F94D6B400EEA7DF /* Build configuration list for PBXNativeTarget "McBopomofoShare" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D44B6DA42F94D6B400EEA7DF /* Debug */, + D44B6DA52F94D6B400EEA7DF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; D485D3BE2796A8A000657FF3 /* Build configuration list for PBXNativeTarget "McBopomofoTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/McBopomofoShare/Base.lproj/Localizable.strings b/McBopomofoShare/Base.lproj/Localizable.strings new file mode 100644 index 00000000..7f5891c0 --- /dev/null +++ b/McBopomofoShare/Base.lproj/Localizable.strings @@ -0,0 +1,18 @@ +"share.loadingContent" = "Loading shared content…"; +"share.noInputItems" = "No shared items were received."; +"share.noSupportedContent" = "No supported shared content was received."; +"share.noDisplayablePhraseEntries" = "No phrase entries are available to display."; +"share.failedToWriteTempFile" = "Unable to write the temporary file."; +"share.failedToCreateUrl" = "Unable to build the McBopomofo URL."; +"share.failedToOpenApp" = "Unable to open McBopomofo."; +"share.error.failedToLoadSharedText" = "Unable to read the shared text."; +"share.error.failedToDecodeSharedText" = "Unable to decode the shared text."; +"share.error.failedToLoadContact" = "Unable to read the shared contact."; +"share.error.failedToParseContact" = "Unable to parse the shared contact."; +"share.error.phraseTooLong" = "\"%@\" exceeds %ld characters."; +"share.error.containsNonChineseCharacters" = "\"%@\" contains non-Chinese characters."; +"share.error.noDisplayablePhraseEntries" = "No valid phrase entries are available to add."; +"share.error.multipleErrorsHeader" = "Some shared content could not be used:"; +"Cancel" = "Cancel"; +"Add" = "Add"; +"Add User Phrases" = "Add User Phrases"; diff --git a/McBopomofoShare/Base.lproj/ShareViewController.xib b/McBopomofoShare/Base.lproj/ShareViewController.xib new file mode 100644 index 00000000..832ca240 --- /dev/null +++ b/McBopomofoShare/Base.lproj/ShareViewController.xib @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/McBopomofoShare/ContactShareInputPlugin.swift b/McBopomofoShare/ContactShareInputPlugin.swift new file mode 100644 index 00000000..0f18627b --- /dev/null +++ b/McBopomofoShare/ContactShareInputPlugin.swift @@ -0,0 +1,124 @@ +// Copyright (c) 2022 and onwards The McBopomofo Authors. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +import Contacts +import Foundation +import UniformTypeIdentifiers + +final class ContactPhraseEntryExtractor { + private let sanitizer = PhraseEntrySanitizer() + + func phraseEntries(from contacts: [CNContact]) -> PhraseEntrySanitizationResult { + let names = contacts + .compactMap(displayName(for:)) + .map(normalizedName(from:)) + + return sanitizer.sanitize(names) + } + + private func displayName(for contact: CNContact) -> String? { + let formatter = CNContactFormatter() + formatter.style = .fullName + if let name = formatter.string(from: contact), !name.isEmpty { + return name + } + + if !contact.organizationName.isEmpty { + return contact.organizationName + } + + return nil + } + + private func normalizedName(from name: String) -> String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + + +final class ContactShareInputPlugin: ShareInputPlugin { + private let extractor = ContactPhraseEntryExtractor() + + func matchingProviders(in providers: [NSItemProvider]) -> [NSItemProvider] { + let vCardType = UTType.vCard.identifier + return providers.filter { $0.hasItemConformingToTypeIdentifier(vCardType) } + } + + func loadPhraseEntries(from providers: [NSItemProvider], completion: @escaping (Result<[String], any Error>) -> Void) { + let group = DispatchGroup() + let lock = NSLock() + var allContacts: [CNContact] = [] + var allErrors: [ShareInputError] = [] + let vCardType = UTType.vCard.identifier + + for provider in providers { + group.enter() + provider.loadDataRepresentation(forTypeIdentifier: vCardType) { data, error in + defer { group.leave() } + + if let error { + NSLog("Failed to load vCard: %@", error.localizedDescription) + lock.lock() + allErrors.append(.failedToLoadContact) + lock.unlock() + return + } + + guard let data else { + lock.lock() + allErrors.append(.failedToLoadContact) + lock.unlock() + return + } + + do { + let contacts = try CNContactVCardSerialization.contacts(with: data) + lock.lock() + allContacts.append(contentsOf: contacts) + lock.unlock() + } catch { + NSLog("Failed to parse vCard: %@", error.localizedDescription) + lock.lock() + allErrors.append(.failedToParseContact) + lock.unlock() + } + } + } + + group.notify(queue: .main) { + let sanitization = self.extractor.phraseEntries(from: allContacts) + let uniqueErrors = (allErrors + sanitization.errors).uniqued() + if !uniqueErrors.isEmpty { + completion(.failure(ShareInputErrorGroup(errors: uniqueErrors))) + return + } + + guard !sanitization.entries.isEmpty else { + completion(.failure(ShareInputError.noDisplayablePhraseEntries)) + return + } + + completion(.success(sanitization.entries)) + } + } +} diff --git a/McBopomofoShare/Info.plist b/McBopomofoShare/Info.plist new file mode 100644 index 00000000..31cce008 --- /dev/null +++ b/McBopomofoShare/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleIconFile + icon + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + SUBQUERY ( + extensionItems, + $extensionItem, + SUBQUERY ( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.vcard" + OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" + ).@count > 0 +).@count > 0 + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/McBopomofoShare/PhraseEntrySanitizer.swift b/McBopomofoShare/PhraseEntrySanitizer.swift new file mode 100644 index 00000000..340a62d0 --- /dev/null +++ b/McBopomofoShare/PhraseEntrySanitizer.swift @@ -0,0 +1,64 @@ +// Copyright (c) 2022 and onwards The McBopomofo Authors. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +import Foundation + +final class PhraseEntrySanitizer { + func sanitize(_ entries: [String]) -> PhraseEntrySanitizationResult { + var sanitized: [String] = [] + var errors: [ShareInputError] = [] + + for entry in entries { + let trimmed = entry.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + continue + } + + guard containsOnlyChineseCharacters(trimmed) else { + errors.append(.containsNonChineseCharacters(trimmed)) + continue + } + + sanitized.append(trimmed) + } + + return PhraseEntrySanitizationResult( + entries: Array(Set(sanitized)).sorted(), + errors: errors.uniqued() + ) + } + + private func containsOnlyChineseCharacters(_ text: String) -> Bool { + !text.isEmpty && text.unicodeScalars.allSatisfy { scalar in + // Match common CJK Unified Ideographs blocks directly because CharacterSet does not offer a precise built-in "Chinese-only" test here. + switch scalar.value { + case 0x3400...0x4DBF, 0x4E00...0x9FFF, 0xF900...0xFAFF, 0x20000...0x2A6DF, + 0x2A700...0x2B73F, 0x2B740...0x2B81F, 0x2B820...0x2CEAF, 0x2CEB0...0x2EBEF, + 0x30000...0x3134F: + return true + default: + return false + } + } + } +} diff --git a/McBopomofoShare/PlainTextShareInputPlugin.swift b/McBopomofoShare/PlainTextShareInputPlugin.swift new file mode 100644 index 00000000..e1e876d1 --- /dev/null +++ b/McBopomofoShare/PlainTextShareInputPlugin.swift @@ -0,0 +1,174 @@ +// Copyright (c) 2022 and onwards The McBopomofo Authors. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +import Foundation +import UniformTypeIdentifiers + +struct PhraseEntryExtractionResult { + let entries: [String] + let errors: [ShareInputError] +} + +final class PlainTextPhraseEntryExtractor { + private let sanitizer = PhraseEntrySanitizer() + private let maxPhraseLength = 8 + + func phraseEntries(from text: String) -> PhraseEntryExtractionResult { + let segments = text + .components(separatedBy: sentenceSeparators) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + var accepted: [String] = [] + var errors: [ShareInputError] = [] + + for segment in segments { + guard segment.count <= maxPhraseLength else { + errors.append(.phraseTooLong(segment, maxLength: maxPhraseLength)) + continue + } + accepted.append(segment) + } + + let sanitization = sanitizer.sanitize(accepted) + return PhraseEntryExtractionResult( + entries: sanitization.entries, + errors: (errors + sanitization.errors).uniqued() + ) + } + + private var sentenceSeparators: CharacterSet { + var set = CharacterSet.newlines + set.formUnion(.punctuationCharacters) + return set + } +} + +final class PlainTextShareInputPlugin: ShareInputPlugin { + private let extractor = PlainTextPhraseEntryExtractor() + + func matchingProviders(in providers: [NSItemProvider]) -> [NSItemProvider] { + providers.filter { + $0.canLoadObject(ofClass: NSString.self) || preferredTextTypeIdentifier(for: $0) != nil + } + } + + func loadPhraseEntries(from providers: [NSItemProvider], completion: @escaping (Result<[String], any Error>) -> Void) { + let group = DispatchGroup() + let lock = NSLock() + var allEntries: [String] = [] + var allErrors: [ShareInputError] = [] + + for provider in providers { + group.enter() + if provider.canLoadObject(ofClass: NSString.self) { + provider.loadObject(ofClass: NSString.self) { object, error in + defer { group.leave() } + + if let error { + NSLog("Failed to load text object: %@", error.localizedDescription) + lock.lock() + allErrors.append(.failedToLoadSharedText) + lock.unlock() + return + } + + guard let text = object as? String else { + lock.lock() + allErrors.append(.failedToDecodeSharedText) + lock.unlock() + return + } + + let result = self.extractor.phraseEntries(from: text) + lock.lock() + allEntries.append(contentsOf: result.entries) + allErrors.append(contentsOf: result.errors) + lock.unlock() + } + continue + } + + guard let typeIdentifier = preferredTextTypeIdentifier(for: provider) else { + group.leave() + continue + } + + provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, error in + defer { group.leave() } + + if let error { + NSLog("Failed to load text data: %@", error.localizedDescription) + lock.lock() + allErrors.append(.failedToLoadSharedText) + lock.unlock() + return + } + + guard let data, + let text = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .unicode) + else { + lock.lock() + allErrors.append(.failedToDecodeSharedText) + lock.unlock() + return + } + + let result = self.extractor.phraseEntries(from: text) + lock.lock() + allEntries.append(contentsOf: result.entries) + allErrors.append(contentsOf: result.errors) + lock.unlock() + } + } + + group.notify(queue: .main) { + let uniqueErrors = allErrors.uniqued() + if !uniqueErrors.isEmpty { + completion(.failure(ShareInputErrorGroup(errors: uniqueErrors))) + return + } + + let uniqueEntries = Array(Set(allEntries)).sorted() + guard !uniqueEntries.isEmpty else { + completion(.failure(ShareInputError.noDisplayablePhraseEntries)) + return + } + + completion(.success(uniqueEntries)) + } + } + + private func preferredTextTypeIdentifier(for provider: NSItemProvider) -> String? { + let preferredTypes = [ + UTType.utf8PlainText.identifier, + UTType.plainText.identifier, + UTType.text.identifier, + "public.utf16-external-plain-text", + "public.utf16-plain-text", + "public.rtf", + ] + + return preferredTypes.first { provider.hasItemConformingToTypeIdentifier($0) } + } +} diff --git a/McBopomofoShare/ShareInputPlugin.swift b/McBopomofoShare/ShareInputPlugin.swift new file mode 100644 index 00000000..2c55a0a0 --- /dev/null +++ b/McBopomofoShare/ShareInputPlugin.swift @@ -0,0 +1,98 @@ +// Copyright (c) 2022 and onwards The McBopomofo Authors. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + + +import Foundation + +protocol ShareInputPlugin { + func matchingProviders(in providers: [NSItemProvider]) -> [NSItemProvider] + func loadPhraseEntries(from providers: [NSItemProvider], completion: @escaping (Result<[String], any Error>) -> Void) +} + +enum ShareInputError: Hashable, LocalizedError { + case failedToLoadSharedText + case failedToDecodeSharedText + case failedToLoadContact + case failedToParseContact + case phraseTooLong(String, maxLength: Int) + case containsNonChineseCharacters(String) + case noDisplayablePhraseEntries + + var errorDescription: String? { + switch self { + case .failedToLoadSharedText: + return NSLocalizedString("share.error.failedToLoadSharedText", comment: "") + case .failedToDecodeSharedText: + return NSLocalizedString("share.error.failedToDecodeSharedText", comment: "") + case .failedToLoadContact: + return NSLocalizedString("share.error.failedToLoadContact", comment: "") + case .failedToParseContact: + return NSLocalizedString("share.error.failedToParseContact", comment: "") + case let .phraseTooLong(phrase, maxLength): + return String( + format: NSLocalizedString("share.error.phraseTooLong", comment: ""), + locale: Locale.current, + phrase, + maxLength + ) + case let .containsNonChineseCharacters(phrase): + return String( + format: NSLocalizedString("share.error.containsNonChineseCharacters", comment: ""), + locale: Locale.current, + phrase + ) + case .noDisplayablePhraseEntries: + return NSLocalizedString("share.error.noDisplayablePhraseEntries", comment: "") + } + } +} + +struct ShareInputErrorGroup: LocalizedError { + let errors: [ShareInputError] + + var errorDescription: String? { + let descriptions = errors.compactMap(\.errorDescription) + guard !descriptions.isEmpty else { + return nil + } + guard descriptions.count > 1 else { + return descriptions[0] + } + + let header = NSLocalizedString("share.error.multipleErrorsHeader", comment: "") + let details = descriptions.map { "• \($0)" }.joined(separator: "\n") + return "\(header)\n\(details)" + } +} + +struct PhraseEntrySanitizationResult { + let entries: [String] + let errors: [ShareInputError] +} + +extension Array where Element: Hashable { + func uniqued() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } + } +} diff --git a/McBopomofoShare/ShareViewController.swift b/McBopomofoShare/ShareViewController.swift new file mode 100644 index 00000000..d4e46349 --- /dev/null +++ b/McBopomofoShare/ShareViewController.swift @@ -0,0 +1,205 @@ +// Copyright (c) 2022 and onwards The McBopomofo Authors. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +import Cocoa +import CoreServices + +class ShareViewController: NSViewController { + private let inputPlugins: [any ShareInputPlugin] = [ + ContactShareInputPlugin(), + PlainTextShareInputPlugin(), + ] + private var phraseEntries: [String] = [] + + private let phraseEntriesTextView = NSTextView(frame: .zero) + private let scrollView = NSScrollView() + @IBOutlet var sendButton: NSButton! + @IBOutlet var cancelButton: NSButton! + @IBOutlet var shareTitle: NSTextField! + + override var nibName: NSNib.Name? { + return NSNib.Name("ShareViewController") + } + + override func loadView() { + super.loadView() + configurePhraseEntriesView() + loadPhraseEntries() + cancelButton.title = NSLocalizedString("Cancel", comment: "") + sendButton.title = NSLocalizedString("Add", comment: "") + shareTitle.stringValue = NSLocalizedString("Add User Phrases", comment: "") + sendButton.isEnabled = false + } + + private func configurePhraseEntriesView() { + phraseEntriesTextView.isEditable = false + phraseEntriesTextView.isSelectable = true + phraseEntriesTextView.drawsBackground = false + phraseEntriesTextView.isHorizontallyResizable = false + phraseEntriesTextView.isVerticallyResizable = true + phraseEntriesTextView.autoresizingMask = [.width] + phraseEntriesTextView.string = NSLocalizedString("share.loadingContent", comment: "") + phraseEntriesTextView.font = NSFont.systemFont(ofSize: NSFont.systemFontSize) + phraseEntriesTextView.textContainerInset = NSSize(width: 0, height: 8) + phraseEntriesTextView.textContainer?.widthTracksTextView = true + phraseEntriesTextView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.borderType = .bezelBorder + scrollView.hasVerticalScroller = true + scrollView.drawsBackground = false + scrollView.documentView = phraseEntriesTextView + + view.addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: 48), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -44), + ]) + } + + private func loadPhraseEntries() { + guard let inputItems = extensionContext?.inputItems as? [NSExtensionItem] else { + phraseEntriesTextView.string = NSLocalizedString("share.noInputItems", comment: "") + return + } + + let providers = providers(from: inputItems) + + if providers.isEmpty { + phraseEntriesTextView.string = NSLocalizedString("share.noSupportedContent", comment: "") + return + } + + guard let selectedMatch = inputPlugins.lazy + .map({ plugin in (plugin: plugin, providers: plugin.matchingProviders(in: providers)) }) + .first(where: { !$0.providers.isEmpty }) + else { + phraseEntriesTextView.string = NSLocalizedString("share.noSupportedContent", comment: "") + return + } + + selectedMatch.plugin.loadPhraseEntries(from: selectedMatch.providers) { result in + switch result { + case let .success(entries): + let uniqueEntries = Array(Set(entries)).sorted() + self.phraseEntries = uniqueEntries + self.sendButton.isEnabled = !uniqueEntries.isEmpty + self.phraseEntriesTextView.string = uniqueEntries.joined(separator: "\n") + case let .failure(error): + self.phraseEntries = [] + self.sendButton.isEnabled = false + self.phraseEntriesTextView.string = error.localizedDescription + } + } + } + + private func providers(from inputItems: [NSExtensionItem]) -> [NSItemProvider] { + let attachmentProviders = inputItems + .compactMap(\.attachments) + .flatMap { $0 } + + if !attachmentProviders.isEmpty { + return attachmentProviders + } + + let attributedTexts = inputItems + .compactMap(\.attributedContentText?.string) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + guard !attributedTexts.isEmpty else { + return [] + } + + let merged = attributedTexts.joined(separator: "\n") + return [NSItemProvider(object: merged as NSString)] + } + + private func writePhraseEntriesToTemporaryFile(_ entries: [String]) throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "McBopomofoShare", + isDirectory: true + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let fileURL = directory.appendingPathComponent(UUID().uuidString).appendingPathExtension("txt") + let content = entries.joined(separator: "\n") + try content.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } + + private func customURL(for fileURL: URL) -> URL? { + var components = URLComponents() + components.scheme = "mcbopomofo" + components.host = "add_phrase" + components.queryItems = [ + URLQueryItem(name: "file", value: fileURL.path), + ] + return components.url + } + + private func completeSharingRequest() { + extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) + } + + @IBAction func send(_ sender: AnyObject?) { + let entries = Array(Set(phraseEntries)).sorted() + guard !entries.isEmpty else { + phraseEntriesTextView.string = NSLocalizedString("share.noDisplayablePhraseEntries", comment: "") + return + } + + sendButton.isEnabled = false + cancelButton.isEnabled = false + + do { + let fileURL = try writePhraseEntriesToTemporaryFile(entries) + guard let customURL = customURL(for: fileURL) else { + phraseEntriesTextView.string = NSLocalizedString("share.failedToCreateUrl", comment: "") + sendButton.isEnabled = true + cancelButton.isEnabled = true + return + } + + if NSWorkspace.shared.open(customURL) { + completeSharingRequest() + } else { + phraseEntriesTextView.string = NSLocalizedString("share.failedToOpenApp", comment: "") + sendButton.isEnabled = true + cancelButton.isEnabled = true + } + } catch { + phraseEntriesTextView.string = NSLocalizedString("share.failedToWriteTempFile", comment: "") + sendButton.isEnabled = true + cancelButton.isEnabled = true + } + } + + @IBAction func cancel(_ sender: AnyObject?) { + let cancelError = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil) + self.extensionContext!.cancelRequest(withError: cancelError) + } + +} diff --git a/McBopomofoShare/en.lproj/InfoPlist.strings b/McBopomofoShare/en.lproj/InfoPlist.strings new file mode 100644 index 00000000..2753d7cd --- /dev/null +++ b/McBopomofoShare/en.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"CFBundleDisplayName" = "Add to McBopomofo"; +"CFBundleName" = "Add to McBopomofo"; diff --git a/McBopomofoShare/en.lproj/Localizable.strings b/McBopomofoShare/en.lproj/Localizable.strings new file mode 100644 index 00000000..7f5891c0 --- /dev/null +++ b/McBopomofoShare/en.lproj/Localizable.strings @@ -0,0 +1,18 @@ +"share.loadingContent" = "Loading shared content…"; +"share.noInputItems" = "No shared items were received."; +"share.noSupportedContent" = "No supported shared content was received."; +"share.noDisplayablePhraseEntries" = "No phrase entries are available to display."; +"share.failedToWriteTempFile" = "Unable to write the temporary file."; +"share.failedToCreateUrl" = "Unable to build the McBopomofo URL."; +"share.failedToOpenApp" = "Unable to open McBopomofo."; +"share.error.failedToLoadSharedText" = "Unable to read the shared text."; +"share.error.failedToDecodeSharedText" = "Unable to decode the shared text."; +"share.error.failedToLoadContact" = "Unable to read the shared contact."; +"share.error.failedToParseContact" = "Unable to parse the shared contact."; +"share.error.phraseTooLong" = "\"%@\" exceeds %ld characters."; +"share.error.containsNonChineseCharacters" = "\"%@\" contains non-Chinese characters."; +"share.error.noDisplayablePhraseEntries" = "No valid phrase entries are available to add."; +"share.error.multipleErrorsHeader" = "Some shared content could not be used:"; +"Cancel" = "Cancel"; +"Add" = "Add"; +"Add User Phrases" = "Add User Phrases"; diff --git a/McBopomofoShare/icon.icns b/McBopomofoShare/icon.icns new file mode 100644 index 00000000..84ae85cd Binary files /dev/null and b/McBopomofoShare/icon.icns differ diff --git a/McBopomofoShare/zh-Hant.lproj/InfoPlist.strings b/McBopomofoShare/zh-Hant.lproj/InfoPlist.strings new file mode 100644 index 00000000..55c1c1af --- /dev/null +++ b/McBopomofoShare/zh-Hant.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"CFBundleDisplayName" = "加到小麥注音"; +"CFBundleName" = "加到小麥注音"; diff --git a/McBopomofoShare/zh-Hant.lproj/Localizable.strings b/McBopomofoShare/zh-Hant.lproj/Localizable.strings new file mode 100644 index 00000000..539691d0 --- /dev/null +++ b/McBopomofoShare/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,18 @@ +"share.loadingContent" = "正在載入分享內容…"; +"share.noInputItems" = "沒有收到分享項目。"; +"share.noSupportedContent" = "沒有收到支援的分享內容。"; +"share.noDisplayablePhraseEntries" = "沒有可顯示的詞彙項目。"; +"share.failedToWriteTempFile" = "無法寫入暫存檔。"; +"share.failedToCreateUrl" = "無法建立小麥注音 URL。"; +"share.failedToOpenApp" = "無法開啟小麥注音。"; +"share.error.failedToLoadSharedText" = "無法讀取分享的文字內容。"; +"share.error.failedToDecodeSharedText" = "無法解碼分享的文字內容。"; +"share.error.failedToLoadContact" = "無法讀取分享的聯絡人。"; +"share.error.failedToParseContact" = "無法解析分享的聯絡人。"; +"share.error.phraseTooLong" = "「%@」超過 %ld 個字元。"; +"share.error.containsNonChineseCharacters" = "「%@」包含非中文字元。"; +"share.error.noDisplayablePhraseEntries" = "沒有可加入的有效詞彙項目。"; +"share.error.multipleErrorsHeader" = "部分分享內容無法使用:"; +"Cancel" = "取消"; +"Add" = "加入"; +"Add User Phrases" = "加入使用者詞彙"; diff --git a/McBopomofoTests/ServiceProviderTests.swift b/McBopomofoTests/ServiceProviderTests.swift index 5ffbd204..d9172912 100644 --- a/McBopomofoTests/ServiceProviderTests.swift +++ b/McBopomofoTests/ServiceProviderTests.swift @@ -28,6 +28,29 @@ import Testing @Suite("Test the service provider", .serialized) final class ServiceProviderTests { + var helper = ServiceProviderInputHelper() + + private func makeService() -> McBopomofoService { + McBopomofoService() + } + + private func makeProvider() -> (ServiceProvider, McBopomofoService) { + let service = makeService() + return (ServiceProvider(service: service), service) + } + + private func makeProviderWithHelper() -> (ServiceProvider, McBopomofoService) { + let service = makeService() + helper = ServiceProviderInputHelper() + if let helper = helper as? McBopomofoServiceDelegate { + service.delegate = helper + helper.mcBopomofoServiceDidRequestReset(service) + } else { + Issue.record("Failed to create McBopomofoServiceDelegate helper") + } + return (ServiceProvider(service: service), service) + } + private func makePasteboard() -> NSPasteboard { NSPasteboard(name: NSPasteboard.Name(UUID().uuidString)) } @@ -54,7 +77,7 @@ final class ServiceProviderTests { ]) func testExtractReading(input: String, expected: String) { - let provider = ServiceProvider() + let (provider, _) = makeProvider() let output = provider.extractReading(from: input) #expect(output == expected) } @@ -67,12 +90,8 @@ final class ServiceProviderTests { ]) func testAddPinyin(input: String, expected: String) { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() - let helper = ServiceProviderInputHelper() - if let helper = helper as? ServiceProviderDelegate { - provider.delegate = helper - } - let output = provider.addHanyuPinyin(string: input) + let (_, service) = makeProviderWithHelper() + let output = service.addHanyuPinyin(string: input) #expect(output == expected) } @@ -87,8 +106,8 @@ final class ServiceProviderTests { ]) func testAddReading(input: String, expected: String) { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() - let output = provider.addReading(string: input) + let (_, service) = makeProvider() + let output = service.addReading(string: input) #expect(output == expected) } @@ -100,12 +119,8 @@ final class ServiceProviderTests { ) func testConvertToPinyin(input: String, expected: String) { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() - let helper = ServiceProviderInputHelper() - if let helper = helper as? ServiceProviderDelegate { - provider.delegate = helper - } - let output = provider.convertToHanyuPinyin(string: input) + let (_, service) = makeProviderWithHelper() + let output = service.convertToHanyuPinyin(string: input) #expect(output == expected) } @@ -117,8 +132,8 @@ final class ServiceProviderTests { ) func testConvertToReadings(input: String, expected: String) { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() - let output = provider.convertToReadings(string: input) + let (_, service) = makeProvider() + let output = service.convertToReadings(string: input) #expect(output == expected) } @@ -129,8 +144,8 @@ final class ServiceProviderTests { ("『這樣可以嗎?』", ["『", "這樣", "可以", "嗎", "?", "』"]), ]) func testTokenize1(input: String, expected: [String]) { - let provider = ServiceProvider() - let output = provider.tokenize(string: input) + let (_, service) = makeProvider() + let output = service.tokenize(string: input) #expect(output.map { $0.0 } == expected, "\(output)") } @@ -141,10 +156,8 @@ final class ServiceProviderTests { ]) func testConvertUnicodeBrailleToChinese(input: String, expected: String) { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() - let helper = ServiceProviderInputHelper() - provider.delegate = helper as? any ServiceProviderDelegate - let output = provider.convertUnicodeBrailleToChineseText(string: input) + let (_, service) = makeProviderWithHelper() + let output = service.convertUnicodeBrailleToChineseText(string: input) #expect(output == expected, "\(output)") } @@ -160,11 +173,9 @@ final class ServiceProviderTests { ]) func testConvertBrailleThenBack(input: String, expected: String) { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() - let helper = ServiceProviderInputHelper() - provider.delegate = helper as? any ServiceProviderDelegate - let r1 = provider.convertToUnicodeBraille(string: input) - let r2 = provider.convertUnicodeBrailleToChineseText(string: r1) + let (_, service) = makeProviderWithHelper() + let r1 = service.convertToUnicodeBraille(string: input) + let r2 = service.convertUnicodeBrailleToChineseText(string: r1) if expected == "" { #expect(r2 == input, "\(r2)") } else { @@ -180,10 +191,8 @@ final class ServiceProviderTests { ]) func testUnicodeBrailleLetters(input: String, expected: String) { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() - let helper = ServiceProviderInputHelper() - provider.delegate = helper as? any ServiceProviderDelegate - let result = provider.convertToUnicodeBraille(string: input) + let (_, service) = makeProviderWithHelper() + let result = service.convertToUnicodeBraille(string: input) #expect(result == expected) } @@ -194,10 +203,8 @@ final class ServiceProviderTests { ]) func testUnicodeBrailleDigit(input: String, expected: String) { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() - let helper = ServiceProviderInputHelper() - provider.delegate = helper as? any ServiceProviderDelegate - let result = provider.convertToUnicodeBraille(string: input) + let (_, service) = makeProviderWithHelper() + let result = service.convertToUnicodeBraille(string: input) #expect(result == expected) } @@ -208,10 +215,8 @@ final class ServiceProviderTests { ]) func testASCIIBrailleLetters(input: String, expected: String) { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() - let helper = ServiceProviderInputHelper() - provider.delegate = helper as? any ServiceProviderDelegate - let result = provider.convertToASCIIBraille(string: input) + let (_, service) = makeProviderWithHelper() + let result = service.convertToASCIIBraille(string: input) #expect(result == expected) } @@ -228,11 +233,9 @@ final class ServiceProviderTests { ]) func testConvertASCIIBrailleThenBack(input: String, expected: String) { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() - let helper = ServiceProviderInputHelper() - provider.delegate = helper as? any ServiceProviderDelegate - let r1 = provider.convertToASCIIBraille(string: input) - let r2 = provider.convertASCIIBrailleToChineseText(string: r1) + let (_, service) = makeProviderWithHelper() + let r1 = service.convertToASCIIBraille(string: input) + let r2 = service.convertASCIIBrailleToChineseText(string: r1) if expected == "" { #expect(r2 == input, "\(r2)") } else { @@ -247,30 +250,28 @@ final class ServiceProviderTests { ]) func testASCIIBrailleDigit(input: String, expected: String) { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() - let helper = ServiceProviderInputHelper() - provider.delegate = helper as? any ServiceProviderDelegate - let result = provider.convertToASCIIBraille(string: input) + let (_, service) = makeProviderWithHelper() + let result = service.convertToASCIIBraille(string: input) #expect(result == expected) } @Test("Test add reading service with pasteboard") func testAddReadingServiceWithPasteboard() { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() + let (provider, service) = makeProvider() let pasteboard = makePasteboard() let input = "美好的朝陽" write(input, to: pasteboard) provider.addReading(pasteboard, userData: nil, error: nil) - #expect(read(from: pasteboard) == provider.addReading(string: input)) + #expect(read(from: pasteboard) == service.addReading(string: input)) } @Test("Test convert to readings service rejects overlong input") func testConvertToReadingsServiceRejectsOverlongInput() { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() + let (provider, _) = makeProvider() let pasteboard = makePasteboard() let input = String(repeating: "美", count: 3000) write(input, to: pasteboard) @@ -283,43 +284,39 @@ final class ServiceProviderTests { @Test("Test convert Unicode braille to Chinese service with pasteboard") func testConvertUnicodeBrailleToChineseServiceWithPasteboard() { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() - let helper = ServiceProviderInputHelper() - provider.delegate = helper as? any ServiceProviderDelegate + let (provider, service) = makeProviderWithHelper() let pasteboard = makePasteboard() let input = "⠰⠤⠋⠺⠂⠻⠄⠛⠥⠂⠓⠫⠐⠑⠳⠄⠪⠐⠙⠮⠁⠅⠎⠐⠊⠱⠐⠑⠪⠄⠏⠣⠄⠇⠶⠐⠤⠆" write(input, to: pasteboard) provider.convertUnicodeBrailleToChineseText(pasteboard, userData: nil, error: nil) - #expect(read(from: pasteboard) == provider.convertUnicodeBrailleToChineseText(string: input)) + #expect(read(from: pasteboard) == service.convertUnicodeBrailleToChineseText(string: input)) } @Test("Test convert ASCII braille to Chinese service with pasteboard") func testConvertASCIIBrailleToChineseServiceWithPasteboard() { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() - let helper = ServiceProviderInputHelper() - provider.delegate = helper as? any ServiceProviderDelegate + let (provider, service) = makeProviderWithHelper() let pasteboard = makePasteboard() - let input = provider.convertToASCIIBraille(string: "「台灣人最需要的就是消波塊」") + let input = service.convertToASCIIBraille(string: "「台灣人最需要的就是消波塊」") write(input, to: pasteboard) provider.convertASCIIBrailleToChineseText(pasteboard, userData: nil, error: nil) - #expect(read(from: pasteboard) == provider.convertASCIIBrailleToChineseText(string: input)) + #expect(read(from: pasteboard) == service.convertASCIIBrailleToChineseText(string: input)) } @Test("Test convert to annotated text service with pasteboard") func testConvertToBpmfAnnotatedTextServiceWithPasteboard() { LanguageModelManager.loadDataModels() - let provider = ServiceProvider() + let (provider, service) = makeProvider() let pasteboard = makePasteboard() let input = "你好" write(input, to: pasteboard) provider.convertToBpmfAnnotatedText(pasteboard, userData: nil, error: nil) - #expect(read(from: pasteboard) == provider.convertToBpmfAnnotatedText(string: input)) + #expect(read(from: pasteboard) == service.convertToBpmfAnnotatedText(string: input)) } } diff --git a/Source/AppDelegate.swift b/Source/AppDelegate.swift index add8cd81..282eba08 100644 --- a/Source/AppDelegate.swift +++ b/Source/AppDelegate.swift @@ -150,8 +150,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NonModalAlertWindowControlle private var checkTask: URLSessionTask? private var updateNextStepURL: URL? private var fsStreamHelper: FSEventStreamHelper? - private var serviceProvider = ServiceProvider() + private var mcBopomofoService = McBopomofoService() private var serviceProviderHelper = ServiceProviderInputHelper() + private lazy var serviceProvider = ServiceProvider(service: mcBopomofoService) + private lazy var customUrlHandler = CustomUrlHandler(service: mcBopomofoService) func updateUserPhrases() { LanguageModelManager.loadUserPhrases(enableForPlainBopomofo: Preferences.enableUserPhrasesInPlainBopomofo) @@ -182,7 +184,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NonModalAlertWindowControlle self.updateUserPhrases() } - serviceProvider.delegate = (serviceProviderHelper as! any ServiceProviderDelegate) + mcBopomofoService.delegate = (serviceProviderHelper as! any McBopomofoServiceDelegate) + (serviceProviderHelper as? any McBopomofoServiceDelegate)? + .mcBopomofoServiceDidRequestReset(mcBopomofoService) NSApp.servicesProvider = serviceProvider enableBopomofoFontAnnotationSupportMenuItemIfRelevantFontsInstalled() @@ -190,6 +194,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NonModalAlertWindowControlle checkForUpdate() } + func application(_ application: NSApplication, open urls: [URL]) { + for url in urls { + customUrlHandler.handle(url) + } + } + @objc func showPreferences() { if preferencesWindowController == nil { preferencesWindowController = PreferencesWindowController(windowNibName: "preferences") diff --git a/Source/CustomUrlHandler.swift b/Source/CustomUrlHandler.swift new file mode 100644 index 00000000..be30bef2 --- /dev/null +++ b/Source/CustomUrlHandler.swift @@ -0,0 +1,108 @@ +// Copyright (c) 2022 and onwards The McBopomofo Authors. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +import Foundation + +final class CustomUrlHandler { + private let service: McBopomofoService + + init(service: McBopomofoService) { + self.service = service + } + + func handle(_ url: URL) { + guard url.scheme == "mcbopomofo" else { + return + } + + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.host == "add_phrase", + let fileValue = components.queryItems?.first(where: { $0.name == "file" })?.value, + let fileURL = phraseFileURL(from: fileValue) + else { + return + } + + importUserPhrases(from: fileURL) + } + + private func phraseFileURL(from value: String) -> URL? { + let fileURL: URL + if value.hasPrefix("/") { + fileURL = URL(fileURLWithPath: value) + } else if let url = URL(string: value), url.isFileURL { + fileURL = url + } else { + return nil + } + let tempDir = FileManager.default.temporaryDirectory.standardizedFileURL + let standardizedURL = fileURL.standardizedFileURL + guard standardizedURL.path.hasPrefix(tempDir.path) else { + return nil + } + return standardizedURL + } + + private func importUserPhrases(from fileURL: URL) { + let maxFileSize = 1024 * 1024 // 1MB limit + guard let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path), + let fileSize = attributes[.size] as? Int, + fileSize <= maxFileSize + else { + return + } + + guard let content = try? String(contentsOf: fileURL, encoding: .utf8) else { + return + } + + let phrases = + content + .components(separatedBy: .newlines) + .filter { line in + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + return !trimmed.hasPrefix("#") + } + .flatMap { line in + line.components(separatedBy: .whitespaces) + } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + if phrases.isEmpty { + return + } + + // Note: Set a maximum number. + let uniquePhrases = Array(Set(phrases)).sorted().prefix(1000) + let added = uniquePhrases.filter { service.addUserPhrase(named: $0) } + + if added.isEmpty { + return + } + + LanguageModelManager.loadUserPhrases( + enableForPlainBopomofo: Preferences.enableUserPhrasesInPlainBopomofo) + LanguageModelManager.loadUserPhraseReplacement() + } +} diff --git a/Source/McBopomofo-Info.plist b/Source/McBopomofo-Info.plist index 805377a2..63efc9ab 100644 --- a/Source/McBopomofo-Info.plist +++ b/Source/McBopomofo-Info.plist @@ -18,6 +18,19 @@ 3.0 CFBundleSignature BPMF + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + org.openvanilla.inputmethod.McBopomofo + CFBundleURLSchemes + + mcbopomofo + + + CFBundleVersion 2264 ComponentInputModeDict diff --git a/Source/McBopomofoService.swift b/Source/McBopomofoService.swift new file mode 100644 index 00000000..dbb13944 --- /dev/null +++ b/Source/McBopomofoService.swift @@ -0,0 +1,316 @@ +// Copyright (c) 2022 and onwards The McBopomofo Authors. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +import BopomofoBraille +import Foundation +import OpenCCBridge + +/// Handles stateful conversion requests used by ``McBopomofoService``. +@objc protocol McBopomofoServiceDelegate: NSObjectProtocol { + /// Inserts a reading into the delegate-managed composing buffer. + @objc(mcBopomofoService:didRequestInsertReading:) + func mcBopomofoService(_ service: McBopomofoService, didRequestInsertReading reading: String) + + /// Commits and returns the current composed text from the delegate. + /// - Returns: The committed text. + @objc(mcBopomofoServiceDidRequestCommitting:) + func mcBopomofoServiceDidRequestCommitting(_ service: McBopomofoService) -> String + + /// Resets the delegate-managed composing state. + @objc(mcBopomofoServiceDidRequestReset:) + func mcBopomofoServiceDidRequestReset(_ service: McBopomofoService) + + /// Converts a Bopomofo reading to Hanyu Pinyin. + /// - Returns: The converted Hanyu Pinyin string. + @objc(mcBopomofoService:didRequestConvertReadingToHanyuPinyin:) + func mcBopomofoService( + _ service: McBopomofoService, didRequestConvertReadingToHanyuPinyin reading: String + ) -> String +} + +class McBopomofoService: NSObject { + weak var delegate: McBopomofoServiceDelegate? + + func extractReading(from firstWord: String) -> String { + var matches: [String] = [] + + // greedily find the longest possible matches + var matchFrom = firstWord.startIndex + while matchFrom < firstWord.endIndex { + let substring = firstWord.suffix(from: matchFrom) + let substringCount = substring.count + + // if an exact match fails, try dropping successive characters from the end to see + // if we can find shorter matches + var drop = 0 + while drop < substringCount { + let candidate = String(substring.dropLast(drop)) + if let converted = OpenCCBridge.shared.convertToTraditional(candidate), + let match = LanguageModelManager.reading(for: converted) + { + // append the match and skip over the matched portion + matches.append(match) + matchFrom = firstWord.index(matchFrom, offsetBy: substringCount - drop) + break + } + drop += 1 + } + + if drop >= substringCount { + // didn't match anything?! + matches.append("?") + matchFrom = firstWord.index(matchFrom, offsetBy: 1) + } + } + + return matches.joined(separator: "-") + } + + func addUserPhrase(named name: String) -> Bool { + guard name.count >= 2, name.count <= 8 else { + return false + } + + let reading = extractReading(from: name) + guard !reading.isEmpty else { + return false + } + + return LanguageModelManager.writeUserPhrase("\(name) \(reading)") + } + + /// Tokenizes the input string with Apple's tokenizer. + /// - Parameter string: The input string. + /// - Returns: An array of token strings and their token types. + func tokenize(string: String) -> [(String, CFStringTokenizerTokenType)] { + let cfString = string as CFString + let tokenizer = CFStringTokenizerCreate( + nil, cfString, CFRange(location: 0, length: CFStringGetLength(cfString)), 0, nil + ) + var readHead = 0 + var output: [(String, CFStringTokenizerTokenType)] = [] + while readHead < CFStringGetLength(cfString) { + let type = CFStringTokenizerAdvanceToNextToken(tokenizer) + let range = CFStringTokenizerGetCurrentTokenRange(tokenizer) + if range.location == kCFNotFound { + if let subString = CFStringCreateWithSubstring( + nil, cfString, CFRangeMake(readHead, 1) + ) { + output.append((subString as String, CFStringTokenizerTokenType.normal)) + } + readHead += 1 + continue + } + + if range.location > readHead { + if let subString = CFStringCreateWithSubstring( + nil, cfString, CFRange(location: readHead, length: range.location - readHead) + ) { + output.append((subString as String, CFStringTokenizerTokenType.normal)) + } + } + if let subString = CFStringCreateWithSubstring(nil, cfString, range) { + output.append((subString as String, type)) + } + readHead = range.location + range.length + } + return output + } + + /// Converts text with reading-aware callbacks. + /// - Parameters: + /// - string: The input string. + /// - addSpace: Whether to insert spaces between CJK and ASCII tokens when needed. + /// - convertEachCharacter: Whether to process non-phrase tokens character by character. + /// - readingFoundCallback: Called when a reading is found for a token or character. + /// - readingNotFoundCallback: Called when no reading is found. + /// - Returns: The transformed string. + private func process( + string: String, + addSpace: Bool, + convertEachCharacter: Bool, + readingFoundCallback: (String, String) -> String, + readingNotFoundCallback: (String) -> String + ) -> String { + var output = "" + let tokens = tokenize(string: string) + + var previousToken: String? + var previousTokenType: CFStringTokenizerTokenType? + + for tokenTuple in tokens { + let token = tokenTuple.0 + let type = tokenTuple.1 + if addSpace, let previousToken, let previousTokenType { + let lastChar = output[output.index(before: output.endIndex)] + if lastChar != " " { + if previousTokenType.contains(.isCJWordMask) + && (!type.contains(.isCJWordMask) && token[token.startIndex].isASCII) + { + output.append(" ") + } else if (!previousTokenType.contains(.isCJWordMask) + && previousToken[previousToken.index(before: previousToken.endIndex)] + .isASCII) + && type.contains(.isCJWordMask) + { + output.append(" ") + } + } + } + previousToken = token + previousTokenType = type + + if let reading = LanguageModelManager.reading(for: token) { + if reading.isEmpty == false && reading.starts(with: "_") == false { + let readings = reading.components(separatedBy: "-") + if readings.count == token.count { + for (index, c) in token.enumerated() { + output += readingFoundCallback(String(c), readings[index]) + } + continue + } + } + } + + var buffer = "" + + for c in token { + if let reading = LanguageModelManager.reading(for: String(c)) { + if reading.isEmpty == false && reading.starts(with: "_") == false { + if convertEachCharacter == false && buffer.isEmpty == false { + output += readingNotFoundCallback(buffer) + buffer = "" + } + output += readingFoundCallback(String(c), reading) + } else { + if convertEachCharacter { + output += readingNotFoundCallback("\(c)") + } else { + buffer += "\(c)" + } + } + } else { + if convertEachCharacter { + output += readingNotFoundCallback("\(c)") + } else { + buffer += "\(c)" + } + } + } + if convertEachCharacter == false && buffer.isEmpty == false { + output += readingNotFoundCallback(buffer) + } + } + return output + } + + func addReading(string: String) -> String { + process(string: string, addSpace: true, convertEachCharacter: true) { + "\($0)(\($1))" + } readingNotFoundCallback: { + $0 + } + } + + func addHanyuPinyin(string: String) -> String { + process(string: string, addSpace: true, convertEachCharacter: true) { + let pinyin = + delegate?.mcBopomofoService(self, didRequestConvertReadingToHanyuPinyin: $1) ?? "" + return "\($0)(\(pinyin))" + } readingNotFoundCallback: { + $0 + } + } + + func convertToReadings(string: String) -> String { + process(string: string, addSpace: false, convertEachCharacter: true) { + $1 + } readingNotFoundCallback: { + $0 + } + } + + func convertToHanyuPinyin(string: String) -> String { + process(string: string, addSpace: true, convertEachCharacter: true) { + delegate?.mcBopomofoService(self, didRequestConvertReadingToHanyuPinyin: $1) ?? $1 + } readingNotFoundCallback: { + $0 + } + } + + private func convertToBraille(string: String, type: BrailleType) -> String { + process(string: string, addSpace: true, convertEachCharacter: false) { + BopomofoBrailleConverter.convert(bopomofo: $1, type: type) + } readingNotFoundCallback: { + BopomofoBrailleConverter.convert(bopomofo: $0, type: type) + } + } + + func convertToUnicodeBraille(string: String) -> String { + convertToBraille(string: string, type: .unicode) + } + + func convertToASCIIBraille(string: String) -> String { + convertToBraille(string: string, type: .ascii) + } + + private func convertBrailleToChineseText(string: String, type: BrailleType) -> String { + delegate?.mcBopomofoServiceDidRequestReset(self) + var output = "" + let tokens = BopomofoBrailleConverter.convert(brailleToTokens: string, type: type) + + for token in tokens { + switch token { + case let token as BopomofoSyllable: + delegate?.mcBopomofoService(self, didRequestInsertReading: token.rawValue) + case let token as String: + if let string = delegate?.mcBopomofoServiceDidRequestCommitting(self) { + output += string + } + output += token + default: + continue + } + } + if let string = delegate?.mcBopomofoServiceDidRequestCommitting(self) { + output += string + } + return output + } + + func convertUnicodeBrailleToChineseText(string: String) -> String { + convertBrailleToChineseText(string: string, type: .unicode) + } + + func convertASCIIBrailleToChineseText(string: String) -> String { + convertBrailleToChineseText(string: string, type: .ascii) + } + + func convertToBpmfAnnotatedText(string: String) -> String { + process(string: string, addSpace: true, convertEachCharacter: true) { + LanguageModelManager.annotateVariant(characters: $0, readings: $1) + } readingNotFoundCallback: { + $0 + } + } +} diff --git a/Source/ServiceProvider.swift b/Source/ServiceProvider.swift index 7899d7c3..34a4d626 100644 --- a/Source/ServiceProvider.swift +++ b/Source/ServiceProvider.swift @@ -22,7 +22,6 @@ // OTHER DEALINGS IN THE SOFTWARE. import AppKit -import BopomofoBraille import OpenCCBridge /// Exposes macOS Services for text transformations powered by McBopomofo. @@ -35,52 +34,17 @@ import OpenCCBridge /// Stateless conversions, such as adding readings or generating Braille, are /// handled directly in this type. Stateful conversions that need access to the /// input method's composing buffer are delegated through -/// ``ServiceProviderDelegate``. +/// ``McBopomofoServiceDelegate``. class ServiceProvider: NSObject { - /// Handles stateful conversion requests that require composing context. - /// - /// Assigning a delegate resets its composing state so each service request - /// starts from a known baseline. - weak var delegate: ServiceProviderDelegate? { - didSet { - delegate?.serviceProvider(didRequestReset: self) - } + private let service: McBopomofoService + + init(service: McBopomofoService) { + self.service = service + super.init() } func extractReading(from firstWord: String) -> String { - var matches: [String] = [] - - // greedily find the longest possible matches - var matchFrom = firstWord.startIndex - while matchFrom < firstWord.endIndex { - let substring = firstWord.suffix(from: matchFrom) - let substringCount = substring.count - - // if an exact match fails, try dropping successive characters from the end to see - // if we can find shorter matches - var drop = 0 - while drop < substringCount { - let candidate = String(substring.dropLast(drop)) - if let converted = OpenCCBridge.shared.convertToTraditional(candidate), - let match = LanguageModelManager.reading(for: converted) - { - // append the match and skip over the matched portion - matches.append(match) - matchFrom = firstWord.index(matchFrom, offsetBy: substringCount - drop) - break - } - drop += 1 - } - - if drop >= substringCount { - // didn't match anything?! - matches.append("?") - matchFrom = firstWord.index(matchFrom, offsetBy: 1) - } - } - - let reading = matches.joined(separator: "-") - return reading + service.extractReading(from: firstWord) } /// Adds the first selected token to the user phrase list. @@ -95,53 +59,16 @@ class ServiceProvider: NSObject { return } - let reading = extractReading(from: firstWord) - - if reading.isEmpty { + if !service.addUserPhrase(named: firstWord) { return } - - LanguageModelManager.writeUserPhrase("\(firstWord) \(reading)") (NSApp.delegate as? AppDelegate)?.openUserPhrases(self) } } -/// Handles stateful conversion requests issued by ``ServiceProvider``. -@objc protocol ServiceProviderDelegate: NSObjectProtocol { - /// Inserts a reading into the delegate-managed composing buffer. - /// - Parameters: - /// - provider: The calling service provider. - /// - didRequestInsertReading: The reading to insert. - @objc(serviceProvider:didRequestInsertReading:) - func serviceProvider(_ provider: ServiceProvider, didRequestInsertReading: String) - - /// Commits and returns the current composed text from the delegate. - /// - Parameter provider: The calling service provider. - /// - Returns: The committed text. - @objc(serviceProviderDidRequestCommitting:) - func serviceProvider(didRequestCommitting provider: ServiceProvider) -> String - - /// Resets the delegate-managed composing state. - /// - Parameter provider: The calling service provider. - @objc(serviceProviderDidRequestReset:) - func serviceProvider(didRequestReset provider: ServiceProvider) - - /// Converts a Bopomofo reading to Hanyu Pinyin. - /// - Parameters: - /// - provider: The calling service provider. - /// - didRequestConvertReadingToHanyuPinyin: The Bopomofo reading to convert. - /// - Returns: The converted Hanyu Pinyin string. - @objc(service:didRequestConvertReadingToHanyuPinyin:) - func serviceProvider(_ provider: ServiceProvider, didRequestConvertReadingToHanyuPinyin: String) -> String -} - -// MARK: - - private let kMaxLength = 3000 extension ServiceProvider { - // MARK: - - private func transformPasteboardString( _ pasteboard: NSPasteboard, maximumLength: Int? = nil, @@ -169,141 +96,8 @@ extension ServiceProvider { pasteboard.writeObjects([output as NSString]) } - /// Tokenizes the input string with Apple's tokenizer. - /// - Parameter string: The input string. - /// - Returns: An array of token strings and their token types. - func tokenize(string: String) -> [(String, CFStringTokenizerTokenType)] { - let cfString = string as CFString - let tokenizer = CFStringTokenizerCreate( - nil, cfString, CFRange(location: 0, length: CFStringGetLength(cfString)), 0, nil - ) - var readHead = 0 - var output: [(String, CFStringTokenizerTokenType)] = [] - while readHead < CFStringGetLength(cfString) { - let type = CFStringTokenizerAdvanceToNextToken(tokenizer) - let range = CFStringTokenizerGetCurrentTokenRange(tokenizer) - if range.location == kCFNotFound { - if let subString = CFStringCreateWithSubstring( - nil, cfString, CFRangeMake(readHead, 1) - ) { - output.append((subString as String, CFStringTokenizerTokenType.normal)) - } - readHead += 1 - continue - } - - if range.location > readHead { - if let subString = CFStringCreateWithSubstring( - nil, cfString, CFRange(location: readHead, length: range.location - readHead) - ) { - output.append((subString as String, CFStringTokenizerTokenType.normal)) - } - } - if let subString = CFStringCreateWithSubstring(nil, cfString, range) { - output.append((subString as String, type)) - } - readHead = range.location + range.length - } - return output - } - - /// Converts text with reading-aware callbacks. - /// - Parameters: - /// - string: The input string. - /// - addSpace: Whether to insert spaces between CJK and ASCII tokens when needed. - /// - convertEachCharacter: Whether to process non-phrase tokens character by character. - /// - readingFoundCallback: Called when a reading is found for a token or character. - /// - readingNotFoundCallback: Called when no reading is found. - /// - Returns: The transformed string. - private func process( - string: String, - addSpace: Bool, - convertEachCharacter: Bool, - readingFoundCallback: (String, String) -> String, - readingNotFoundCallback: (String) -> String - ) -> String { - var output = "" - let tokens = tokenize(string: string) - - var previousToken: String? - var previousTokenType: CFStringTokenizerTokenType? - - for tokenTuple in tokens { - let token = tokenTuple.0 - let type = tokenTuple.1 - if addSpace, let previousToken, let previousTokenType { - let lastChar = output[output.index(before: output.endIndex)] - if lastChar != " " { - if previousTokenType.contains(.isCJWordMask) - && (!type.contains(.isCJWordMask) && token[token.startIndex].isASCII) - { - output.append(" ") - } else if (!previousTokenType.contains(.isCJWordMask) - && previousToken[previousToken.index(before: previousToken.endIndex)] - .isASCII) - && type.contains(.isCJWordMask) - { - output.append(" ") - } - } - } - previousToken = token - previousTokenType = type - - if let reading = LanguageModelManager.reading(for: token) { - if reading.isEmpty == false && reading.starts(with: "_") == false { - let readings = reading.components(separatedBy: "-") - if readings.count == token.count { - for (index, c) in token.enumerated() { - output += readingFoundCallback(String(c), readings[index]) - } - continue - } - } - } - - var buffer = "" - - for c in token { - if let reading = LanguageModelManager.reading(for: String(c)) { - if reading.isEmpty == false && reading.starts(with: "_") == false { - if convertEachCharacter == false && buffer.isEmpty == false { - output += readingNotFoundCallback(buffer) - buffer = "" - } - output += readingFoundCallback(String(c), reading) - } else { - if convertEachCharacter { - output += readingNotFoundCallback("\(c)") - } else { - buffer += "\(c)" - } - } - } else { - if convertEachCharacter { - output += readingNotFoundCallback("\(c)") - } else { - buffer += "\(c)" - } - } - } - if convertEachCharacter == false && buffer.isEmpty == false { - output += readingNotFoundCallback(buffer) - } - } - return output - } - // MARK: - Add readings - func addReading(string: String) -> String { - process(string: string, addSpace: true, convertEachCharacter: true) { - "\($0)(\($1))" - } readingNotFoundCallback: { - $0 - } - } - /// Adds Bopomofo readings to the selected text. @objc func addReading(_ pasteboard: NSPasteboard, userData _: String?, error _: NSErrorPointer) { transformPasteboardString( @@ -316,20 +110,11 @@ extension ServiceProvider { } return converted.components(separatedBy: "\n").map { input in - addReading(string: input) + service.addReading(string: input) }.joined(separator: "\n") } } - func addHanyuPinyin(string: String) -> String { - process(string: string, addSpace: true, convertEachCharacter: true) { - let pinyin = delegate?.serviceProvider(self, didRequestConvertReadingToHanyuPinyin: $1) ?? "" - return "\($0)(\(pinyin))" - } readingNotFoundCallback: { - $0 - } - } - /// Adds Hanyu Pinyin readings to the selected text. @objc func addHanyuPinyin(_ pasteboard: NSPasteboard, userData _: String?, error _: NSErrorPointer) { transformPasteboardString( @@ -342,157 +127,85 @@ extension ServiceProvider { } return converted.components(separatedBy: "\n").map { input in - addHanyuPinyin(string: input) + service.addHanyuPinyin(string: input) }.joined(separator: "\n") } } // MARK: - Convert to readings - func convertToReadings(string: String) -> String { - process(string: string, addSpace: false, convertEachCharacter: true) { - $1 - } readingNotFoundCallback: { - $0 - } - } - /// Converts the selected text to Bopomofo readings. @objc func convertToReadings( _ pasteboard: NSPasteboard, userData _: String?, error _: NSErrorPointer ) { transformPasteboardString(pasteboard, maximumLength: kMaxLength) { string in string.components(separatedBy: "\n").map { input in - convertToReadings(string: input) + service.convertToReadings(string: input) }.joined(separator: "\n") } } - func convertToHanyuPinyin(string: String) -> String { - process(string: string, addSpace: true, convertEachCharacter: true) { - delegate?.serviceProvider(self, didRequestConvertReadingToHanyuPinyin: $1) ?? $1 - } readingNotFoundCallback: { - $0 - } - } - /// Converts the selected text to Hanyu Pinyin. @objc func convertToHanyuPinyin( _ pasteboard: NSPasteboard, userData _: String?, error _: NSErrorPointer ) { transformPasteboardString(pasteboard, maximumLength: kMaxLength) { string in string.components(separatedBy: "\n").map { input in - convertToHanyuPinyin(string: input) + service.convertToHanyuPinyin(string: input) }.joined(separator: "\n") } } // MARK: - Braille - private func convertToBraille(string: String, type: BrailleType) -> String { - process(string: string, addSpace: true, convertEachCharacter: false) { - BopomofoBrailleConverter.convert(bopomofo: $1, type: type) - } readingNotFoundCallback: { - BopomofoBrailleConverter.convert(bopomofo: $0, type: type) - } - } - - func convertToUnicodeBraille(string: String) -> String { - convertToBraille(string: string, type: .unicode) - } - /// Converts selected text to Unicode Taiwanese Braille. @objc func convertToUnicodeBraille( _ pasteboard: NSPasteboard, userData _: String?, error _: NSErrorPointer ) { transformPasteboardString(pasteboard, maximumLength: kMaxLength) { string in string.components(separatedBy: "\n").map { input in - convertToUnicodeBraille(string: input) + service.convertToUnicodeBraille(string: input) }.joined(separator: "\n") } } - func convertToASCIIBraille(string: String) -> String { - convertToBraille(string: string, type: .ascii) - } - /// Converts selected text to ASCII Taiwanese Braille. @objc func convertToASCIIBraille( _ pasteboard: NSPasteboard, userData _: String?, error _: NSErrorPointer ) { transformPasteboardString(pasteboard, maximumLength: kMaxLength) { string in string.components(separatedBy: "\n").map { input in - convertToASCIIBraille(string: input) + service.convertToASCIIBraille(string: input) }.joined(separator: "\n") } } - private func convertBrailleToChineseText(string: String, type: BrailleType) -> String { - delegate?.serviceProvider(didRequestReset: self) - var output = "" - let tokens = BopomofoBrailleConverter.convert(brailleToTokens: string, type: type) - - for token in tokens { - switch token { - case let token as BopomofoSyllable: - delegate?.serviceProvider(self, didRequestInsertReading: token.rawValue) - case let token as String: - if let string = delegate?.serviceProvider(didRequestCommitting: self) { - output += string - } - output += token - default: - continue - } - } - if let string = delegate?.serviceProvider(didRequestCommitting: self) { - output += string - } - return output - } - - func convertUnicodeBrailleToChineseText(string: String) -> String { - convertBrailleToChineseText(string: string, type: .unicode) - } - /// Converts the selected Unicode Taiwanese Braille to Chinese text. @objc func convertUnicodeBrailleToChineseText( _ pasteboard: NSPasteboard, userData _: String?, error _: NSErrorPointer ) { transformPasteboardString(pasteboard, skipIfOutputIsEmpty: true) { string in - convertUnicodeBrailleToChineseText(string: string) + service.convertUnicodeBrailleToChineseText(string: string) } } - func convertASCIIBrailleToChineseText(string: String) -> String { - convertBrailleToChineseText(string: string, type: .ascii) - } - /// Converts the selected ASCII Taiwanese Braille to Chinese text. @objc func convertASCIIBrailleToChineseText( _ pasteboard: NSPasteboard, userData _: String?, error _: NSErrorPointer ) { transformPasteboardString(pasteboard, skipIfOutputIsEmpty: true) { string in - convertASCIIBrailleToChineseText(string: string) + service.convertASCIIBrailleToChineseText(string: string) } } // MARK: - BPMF vs font - func convertToBpmfAnnotatedText(string: String) -> String { - process(string: string, addSpace: true, convertEachCharacter: true) { - LanguageModelManager.annotateVariant(characters: $0, readings: $1) - } readingNotFoundCallback: { - $0 - } - } - /// Converts the selected text to annotated text for BPMF VS font support. @objc func convertToBpmfAnnotatedText( _ pasteboard: NSPasteboard, userData _: String?, error _: NSErrorPointer ) { transformPasteboardString(pasteboard, skipIfOutputIsEmpty: true) { string in - convertToBpmfAnnotatedText(string: string) + service.convertToBpmfAnnotatedText(string: string) } } } diff --git a/Source/ServiceProviderInputHelper.mm b/Source/ServiceProviderInputHelper.mm index b4e916cf..7cb6f857 100644 --- a/Source/ServiceProviderInputHelper.mm +++ b/Source/ServiceProviderInputHelper.mm @@ -34,7 +34,7 @@ @interface ServiceProviderInputHelper() } @end -@interface ServiceProviderInputHelper(ServiceProviderDelegate) +@interface ServiceProviderInputHelper(McBopomofoServiceDelegate) @end @implementation ServiceProviderInputHelper @@ -56,7 +56,7 @@ - (instancetype)init @end -@implementation ServiceProviderInputHelper(ServiceProviderDelegate) +@implementation ServiceProviderInputHelper(McBopomofoServiceDelegate) - (void)reset { @@ -64,12 +64,12 @@ - (void)reset } -- (void)serviceProvider:(ServiceProvider * _Nonnull)provider didRequestInsertReading:(NSString * _Nonnull)didRequestInsertReading +- (void)mcBopomofoService:(McBopomofoService * _Nonnull)service didRequestInsertReading:(NSString * _Nonnull)didRequestInsertReading { _grid->insertReading(didRequestInsertReading.UTF8String); } -- (NSString * _Nonnull)serviceProviderDidRequestCommitting:(ServiceProvider * _Nonnull)provider +- (NSString * _Nonnull)mcBopomofoServiceDidRequestCommitting:(McBopomofoService * _Nonnull)service { Formosa::Gramambular2::ReadingGrid::WalkResult _latestWalk = _grid->walk(); std::string output; @@ -80,12 +80,12 @@ - (NSString * _Nonnull)serviceProviderDidRequestCommitting:(ServiceProvider * _N return [NSString stringWithUTF8String:output.c_str()]; } -- (void)serviceProviderDidRequestReset:(ServiceProvider * _Nonnull)provider +- (void)mcBopomofoServiceDidRequestReset:(McBopomofoService * _Nonnull)service { [self reset]; } -- (NSString * _Nonnull)service:(ServiceProvider * _Nonnull)provider didRequestConvertReadingToHanyuPinyin:(NSString * _Nonnull)input +- (NSString * _Nonnull)mcBopomofoService:(McBopomofoService * _Nonnull)service didRequestConvertReadingToHanyuPinyin:(NSString * _Nonnull)input { std::string reading = std::string([input UTF8String]); Formosa::Mandarin::BopomofoSyllable syllable = Formosa::Mandarin::BopomofoSyllable::FromComposedString(reading);