diff --git a/MastoSearch.xcodeproj/project.pbxproj b/MastoSearch.xcodeproj/project.pbxproj index 3e0bb07..fe76bfe 100644 --- a/MastoSearch.xcodeproj/project.pbxproj +++ b/MastoSearch.xcodeproj/project.pbxproj @@ -7,34 +7,94 @@ objects = { /* Begin PBXBuildFile section */ + D6451241276981A40046CCD2 /* WindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6451240276981A40046CCD2 /* WindowController.swift */; }; + D6451243276A408F0046CCD2 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6451242276A408F0046CCD2 /* LocalData.swift */; }; + D669039E2769236F00819C4D /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D669039C2769236F00819C4D /* ViewController.swift */; }; + D66903BE2769250B00819C4D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D66903BD2769250B00819C4D /* Main.storyboard */; }; + D66903C127692EAB00819C4D /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D66903C027692EAB00819C4D /* SwiftSoup */; }; + D6A4B8A827C1BC5A0016F458 /* APIController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4B8A727C1BC5A0016F458 /* APIController.swift */; }; + D6A4B8B027C2B1770016F458 /* MastoSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4B8AF27C2B1770016F458 /* MastoSearchTests.swift */; }; + D6A4B8B727C2B18C0016F458 /* ImportControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4B8B627C2B18C0016F458 /* ImportControllerTests.swift */; }; + D6A4B8BA27C2BE330016F458 /* UInt128.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4B8B927C2BE330016F458 /* UInt128.swift */; }; D6B24DE927640CE100BA23B8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B24DE827640CE100BA23B8 /* AppDelegate.swift */; }; D6B24DEB27640CE200BA23B8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6B24DEA27640CE200BA23B8 /* Assets.xcassets */; }; - D6B24DEE27640CE200BA23B8 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6B24DEC27640CE200BA23B8 /* MainMenu.xib */; }; + D6B24DF727640D2600BA23B8 /* FMDB in Frameworks */ = {isa = PBXBuildFile; productRef = D6B24DF627640D2600BA23B8 /* FMDB */; }; + D6B24DF927640DD700BA23B8 /* DatabaseController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B24DF827640DD700BA23B8 /* DatabaseController.swift */; }; + D6D9CFE82764196E006FE2E7 /* ImportController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9CFE72764196E006FE2E7 /* ImportController.swift */; }; + D6D9CFEA27641D4A006FE2E7 /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9CFE927641D4A006FE2E7 /* Status.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + D6A4B8B127C2B1770016F458 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D6B24DDD27640CE100BA23B8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D6B24DE427640CE100BA23B8; + remoteInfo = MastoSearch; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + D6451240276981A40046CCD2 /* WindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowController.swift; sourceTree = ""; }; + D6451242276A408F0046CCD2 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = ""; }; + D669039C2769236F00819C4D /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + D66903BD2769250B00819C4D /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; + D6A4B8A727C1BC5A0016F458 /* APIController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIController.swift; sourceTree = ""; }; + D6A4B8AD27C2B1770016F458 /* MastoSearchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MastoSearchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D6A4B8AF27C2B1770016F458 /* MastoSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastoSearchTests.swift; sourceTree = ""; }; + D6A4B8B627C2B18C0016F458 /* ImportControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportControllerTests.swift; sourceTree = ""; }; + D6A4B8B927C2BE330016F458 /* UInt128.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UInt128.swift; sourceTree = ""; }; D6B24DE527640CE100BA23B8 /* MastoSearch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MastoSearch.app; sourceTree = BUILT_PRODUCTS_DIR; }; D6B24DE827640CE100BA23B8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; D6B24DEA27640CE200BA23B8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - D6B24DED27640CE200BA23B8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; D6B24DEF27640CE200BA23B8 /* MastoSearch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MastoSearch.entitlements; sourceTree = ""; }; + D6B24DF827640DD700BA23B8 /* DatabaseController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseController.swift; sourceTree = ""; }; + D6D9CFE72764196E006FE2E7 /* ImportController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportController.swift; sourceTree = ""; }; + D6D9CFE927641D4A006FE2E7 /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - D6B24DE227640CE100BA23B8 /* Frameworks */ = { + D6A4B8AA27C2B1770016F458 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; + D6B24DE227640CE100BA23B8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D6B24DF727640D2600BA23B8 /* FMDB in Frameworks */, + D66903C127692EAB00819C4D /* SwiftSoup in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + D6A4B8AE27C2B1770016F458 /* MastoSearchTests */ = { + isa = PBXGroup; + children = ( + D6A4B8AF27C2B1770016F458 /* MastoSearchTests.swift */, + D6A4B8B627C2B18C0016F458 /* ImportControllerTests.swift */, + ); + path = MastoSearchTests; + sourceTree = ""; + }; + D6A4B8B827C2BE250016F458 /* Vendor */ = { + isa = PBXGroup; + children = ( + D6A4B8B927C2BE330016F458 /* UInt128.swift */, + ); + path = Vendor; + sourceTree = ""; + }; D6B24DDC27640CE100BA23B8 = { isa = PBXGroup; children = ( D6B24DE727640CE100BA23B8 /* MastoSearch */, + D6A4B8AE27C2B1770016F458 /* MastoSearchTests */, D6B24DE627640CE100BA23B8 /* Products */, ); sourceTree = ""; @@ -43,6 +103,7 @@ isa = PBXGroup; children = ( D6B24DE527640CE100BA23B8 /* MastoSearch.app */, + D6A4B8AD27C2B1770016F458 /* MastoSearchTests.xctest */, ); name = Products; sourceTree = ""; @@ -51,8 +112,16 @@ isa = PBXGroup; children = ( D6B24DE827640CE100BA23B8 /* AppDelegate.swift */, + D6451242276A408F0046CCD2 /* LocalData.swift */, + D6B24DF827640DD700BA23B8 /* DatabaseController.swift */, + D6D9CFE72764196E006FE2E7 /* ImportController.swift */, + D6A4B8A727C1BC5A0016F458 /* APIController.swift */, + D6D9CFE927641D4A006FE2E7 /* Status.swift */, + D6451240276981A40046CCD2 /* WindowController.swift */, + D669039C2769236F00819C4D /* ViewController.swift */, + D6A4B8B827C2BE250016F458 /* Vendor */, D6B24DEA27640CE200BA23B8 /* Assets.xcassets */, - D6B24DEC27640CE200BA23B8 /* MainMenu.xib */, + D66903BD2769250B00819C4D /* Main.storyboard */, D6B24DEF27640CE200BA23B8 /* MastoSearch.entitlements */, ); path = MastoSearch; @@ -61,6 +130,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + D6A4B8AC27C2B1770016F458 /* MastoSearchTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D6A4B8B327C2B1770016F458 /* Build configuration list for PBXNativeTarget "MastoSearchTests" */; + buildPhases = ( + D6A4B8A927C2B1770016F458 /* Sources */, + D6A4B8AA27C2B1770016F458 /* Frameworks */, + D6A4B8AB27C2B1770016F458 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D6A4B8B227C2B1770016F458 /* PBXTargetDependency */, + ); + name = MastoSearchTests; + productName = MastoSearchTests; + productReference = D6A4B8AD27C2B1770016F458 /* MastoSearchTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; D6B24DE427640CE100BA23B8 /* MastoSearch */ = { isa = PBXNativeTarget; buildConfigurationList = D6B24DF227640CE200BA23B8 /* Build configuration list for PBXNativeTarget "MastoSearch" */; @@ -74,6 +161,10 @@ dependencies = ( ); name = MastoSearch; + packageProductDependencies = ( + D6B24DF627640D2600BA23B8 /* FMDB */, + D66903C027692EAB00819C4D /* SwiftSoup */, + ); productName = MastoSearch; productReference = D6B24DE527640CE100BA23B8 /* MastoSearch.app */; productType = "com.apple.product-type.application"; @@ -88,6 +179,10 @@ LastSwiftUpdateCheck = 1320; LastUpgradeCheck = 1320; TargetAttributes = { + D6A4B8AC27C2B1770016F458 = { + CreatedOnToolsVersion = 13.2; + TestTargetID = D6B24DE427640CE100BA23B8; + }; D6B24DE427640CE100BA23B8 = { CreatedOnToolsVersion = 13.2; }; @@ -102,50 +197,112 @@ Base, ); mainGroup = D6B24DDC27640CE100BA23B8; + packageReferences = ( + D6B24DF527640D2600BA23B8 /* XCRemoteSwiftPackageReference "fmdb" */, + D66903BF27692EAB00819C4D /* XCRemoteSwiftPackageReference "SwiftSoup" */, + ); productRefGroup = D6B24DE627640CE100BA23B8 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( D6B24DE427640CE100BA23B8 /* MastoSearch */, + D6A4B8AC27C2B1770016F458 /* MastoSearchTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + D6A4B8AB27C2B1770016F458 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D6B24DE327640CE100BA23B8 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( D6B24DEB27640CE200BA23B8 /* Assets.xcassets in Resources */, - D6B24DEE27640CE200BA23B8 /* MainMenu.xib in Resources */, + D66903BE2769250B00819C4D /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + D6A4B8A927C2B1770016F458 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D6A4B8B727C2B18C0016F458 /* ImportControllerTests.swift in Sources */, + D6A4B8B027C2B1770016F458 /* MastoSearchTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D6B24DE127640CE100BA23B8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D669039E2769236F00819C4D /* ViewController.swift in Sources */, + D6451241276981A40046CCD2 /* WindowController.swift in Sources */, + D6B24DF927640DD700BA23B8 /* DatabaseController.swift in Sources */, D6B24DE927640CE100BA23B8 /* AppDelegate.swift in Sources */, + D6A4B8A827C1BC5A0016F458 /* APIController.swift in Sources */, + D6D9CFE82764196E006FE2E7 /* ImportController.swift in Sources */, + D6A4B8BA27C2BE330016F458 /* UInt128.swift in Sources */, + D6D9CFEA27641D4A006FE2E7 /* Status.swift in Sources */, + D6451243276A408F0046CCD2 /* LocalData.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXVariantGroup section */ - D6B24DEC27640CE200BA23B8 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - D6B24DED27640CE200BA23B8 /* Base */, - ); - name = MainMenu.xib; - sourceTree = ""; +/* Begin PBXTargetDependency section */ + D6A4B8B227C2B1770016F458 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D6B24DE427640CE100BA23B8 /* MastoSearch */; + targetProxy = D6A4B8B127C2B1770016F458 /* PBXContainerItemProxy */; }; -/* End PBXVariantGroup section */ +/* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + D6A4B8B427C2B1770016F458 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZPBBSK8L8B; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.MastoSearchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MastoSearch.app/Contents/MacOS/MastoSearch"; + }; + name = Debug; + }; + D6A4B8B527C2B1770016F458 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZPBBSK8L8B; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.MastoSearchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MastoSearch.app/Contents/MacOS/MastoSearch"; + }; + name = Release; + }; D6B24DF027640CE200BA23B8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -274,7 +431,7 @@ ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSMainNibFile = MainMenu; + INFOPLIST_KEY_NSMainStoryboardFile = Main; INFOPLIST_KEY_NSPrincipalClass = NSApplication; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -301,7 +458,7 @@ ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSMainNibFile = MainMenu; + INFOPLIST_KEY_NSMainStoryboardFile = Main; INFOPLIST_KEY_NSPrincipalClass = NSApplication; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -318,6 +475,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + D6A4B8B327C2B1770016F458 /* Build configuration list for PBXNativeTarget "MastoSearchTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D6A4B8B427C2B1770016F458 /* Debug */, + D6A4B8B527C2B1770016F458 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; D6B24DE027640CE100BA23B8 /* Build configuration list for PBXProject "MastoSearch" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -337,6 +503,38 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + D66903BF27692EAB00819C4D /* XCRemoteSwiftPackageReference "SwiftSoup" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/scinfu/SwiftSoup"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 2.3.4; + }; + }; + D6B24DF527640D2600BA23B8 /* XCRemoteSwiftPackageReference "fmdb" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ccgus/fmdb"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 2.7.7; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + D66903C027692EAB00819C4D /* SwiftSoup */ = { + isa = XCSwiftPackageProductDependency; + package = D66903BF27692EAB00819C4D /* XCRemoteSwiftPackageReference "SwiftSoup" */; + productName = SwiftSoup; + }; + D6B24DF627640D2600BA23B8 /* FMDB */ = { + isa = XCSwiftPackageProductDependency; + package = D6B24DF527640D2600BA23B8 /* XCRemoteSwiftPackageReference "fmdb" */; + productName = FMDB; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = D6B24DDD27640CE100BA23B8 /* Project object */; } diff --git a/MastoSearch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/MastoSearch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..f622059 --- /dev/null +++ b/MastoSearch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,25 @@ +{ + "object": { + "pins": [ + { + "package": "FMDB", + "repositoryURL": "https://github.com/ccgus/fmdb", + "state": { + "branch": null, + "revision": "61e51fde7f7aab6554f30ab061cc588b28a97d04", + "version": "2.7.7" + } + }, + { + "package": "SwiftSoup", + "repositoryURL": "https://github.com/scinfu/SwiftSoup", + "state": { + "branch": null, + "revision": "3fa09f4d79e5172b14cb50e02f1d5f115a2bbaef", + "version": "2.3.4" + } + } + ] + }, + "version": 1 +} diff --git a/MastoSearch.xcodeproj/project.xcworkspace/xcuserdata/shadowfacts.xcuserdatad/IDEFindNavigatorScopes.plist b/MastoSearch.xcodeproj/project.xcworkspace/xcuserdata/shadowfacts.xcuserdatad/IDEFindNavigatorScopes.plist new file mode 100644 index 0000000..5dd5da8 --- /dev/null +++ b/MastoSearch.xcodeproj/project.xcworkspace/xcuserdata/shadowfacts.xcuserdatad/IDEFindNavigatorScopes.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/MastoSearch.xcodeproj/xcshareddata/xcschemes/MastoSearch.xcscheme b/MastoSearch.xcodeproj/xcshareddata/xcschemes/MastoSearch.xcscheme new file mode 100644 index 0000000..10687b9 --- /dev/null +++ b/MastoSearch.xcodeproj/xcshareddata/xcschemes/MastoSearch.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MastoSearch.xcodeproj/xcuserdata/shadowfacts.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/MastoSearch.xcodeproj/xcuserdata/shadowfacts.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..3274ecf --- /dev/null +++ b/MastoSearch.xcodeproj/xcuserdata/shadowfacts.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/MastoSearch.xcodeproj/xcuserdata/shadowfacts.xcuserdatad/xcschemes/xcschememanagement.plist b/MastoSearch.xcodeproj/xcuserdata/shadowfacts.xcuserdatad/xcschemes/xcschememanagement.plist index dbffe9a..cb22af5 100644 --- a/MastoSearch.xcodeproj/xcuserdata/shadowfacts.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/MastoSearch.xcodeproj/xcuserdata/shadowfacts.xcuserdatad/xcschemes/xcschememanagement.plist @@ -10,5 +10,18 @@ 0 + SuppressBuildableAutocreation + + D6A4B8AC27C2B1770016F458 + + primary + + + D6B24DE427640CE100BA23B8 + + primary + + + diff --git a/MastoSearch/APIController.swift b/MastoSearch/APIController.swift new file mode 100644 index 0000000..8fc7759 --- /dev/null +++ b/MastoSearch/APIController.swift @@ -0,0 +1,196 @@ +// +// APIController.swift +// MastoSearch +// +// Created by Shadowfacts on 2/19/22. +// + +import Cocoa + +struct APIController { + + static let shared = APIController() + + let scopes = "read" + let redirectScheme = "mastosearch" + let redirectURI = "mastosearch://oauth" + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ" + formatter.timeZone = TimeZone(abbreviation: "UTC") + formatter.locale = Locale(identifier: "en_US_POSIX") + let iso8601 = ISO8601DateFormatter() + decoder.dateDecodingStrategy = .custom({ (decoder) in + let container = try decoder.singleValueContainer() + let str = try container.decode(String.self) + // for the next time mastodon accidentally changes date formats >.> + if let date = formatter.date(from: str) { + return date + } else if let date = iso8601.date(from: str) { + return date + } else { + throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format")) + } + }) + + return decoder + }() + + private init() {} + + private func run(request: URLRequest, completion: @escaping (Result) -> Void) { + var request = request + if let accessToken = LocalData.account?.accessToken { + request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + } + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(.error(error))) + return + } + let response = response as! HTTPURLResponse + guard response.statusCode == 200 else { + completion(.failure(.unexpectedStatusCode(response.statusCode))) + return + } + guard let data = data else { + completion(.failure(.noData)) + return + } + do { + let statuses = try decoder.decode(R.self, from: data) + completion(.success(statuses)) + } catch { + completion(.failure(.decoding(error))) + return + } + } + task.resume() + } + + func register(completion: @escaping (Result) -> Void) { + guard let account = LocalData.account else { + return + } + + var components = URLComponents(url: account.instanceURL, resolvingAgainstBaseURL: false)! + components.path = "/api/v1/apps" + var req = URLRequest(url: components.url!) + req.httpMethod = "POST" + req.httpBody = [ + ("client_name", "MastoSearch"), + ("redirect_uris", redirectURI), + ("scopes", scopes), + ].map { "\($0.0)=\($0.1)" }.joined(separator: "&").data(using: .utf8)! + run(request: req, completion: completion) + } + + func getAccessToken(authCode: String, completion: @escaping (Result) -> Void) { + guard let account = LocalData.account else { + return + } + + var components = URLComponents(url: account.instanceURL, resolvingAgainstBaseURL: false)! + components.path = "/oauth/token" + var req = URLRequest(url: components.url!) + req.httpMethod = "POST" + req.httpBody = [ + ("client_id", account.clientID!), + ("client_secret", account.clientSecret!), + ("grant_type", "authorization_code"), + ("code", authCode), + ("redirect_uri", redirectURI), + ].map { "\($0.0)=\($0.1)" }.joined(separator: "&").data(using: .utf8)! + run(request: req, completion: completion) + } + + func getStatuses(range: RequestRange, completion: @escaping (Result<[Status], Error>) -> Void) { + guard let account = LocalData.account else { + return + } + var components = URLComponents(url: account.instanceURL, resolvingAgainstBaseURL: false)! + components.path = "/api/v1/accounts/1/statuses" + components.queryItems = range.queryParameters + [ + URLQueryItem(name: "exclude_replies", value: "false"), + ] + run(request: URLRequest(url: components.url!), completion: completion) + } + +} + +extension APIController { + enum RequestRange { + case `default` + case after(String) + + var queryParameters: [URLQueryItem] { + switch self { + case .default: + return [] + case .after(let id): + return [URLQueryItem(name: "min_id", value: id)] + } + } + } + + struct ClientRegistration: Decodable { + let client_id: String + let client_secret: String + } + + struct LoginSettings: Decodable { + let access_token: String + } + + struct Status: Decodable { + let id: String + let url: String + let spoiler_text: String + let content: String + let created_at: Date + let hasReblog: Bool + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.url = try container.decode(String.self, forKey: .url) + self.spoiler_text = try container.decode(String.self, forKey: .spoiler_text) + self.content = try container.decode(String.self, forKey: .content) + self.created_at = try container.decode(Date.self, forKey: .created_at) + if container.contains(.reblog) { + self.hasReblog = !(try container.decodeNil(forKey: .reblog)) + } else { + self.hasReblog = false + } + } + + enum CodingKeys: String, CodingKey { + case id, url, spoiler_text, content, created_at, reblog + } + } +} + +extension APIController { + enum Error: Swift.Error { + case unexpectedStatusCode(Int) + case error(Swift.Error) + case noData + case decoding(Swift.Error) + + var localizedDescription: String { + switch self { + case .unexpectedStatusCode(let code): + return "Unexpected status code \(code)" + case .error(let inner): + return inner.localizedDescription + case .noData: + return "No data" + case .decoding(let error): + return "Decoding: \(error.localizedDescription)" + } + } + } +} diff --git a/MastoSearch/AppDelegate.swift b/MastoSearch/AppDelegate.swift index 2c458ea..026219b 100644 --- a/MastoSearch/AppDelegate.swift +++ b/MastoSearch/AppDelegate.swift @@ -6,25 +6,189 @@ // import Cocoa +import UniformTypeIdentifiers +import AuthenticationServices +import Combine +import OSLog @main class AppDelegate: NSObject, NSApplicationDelegate { - @IBOutlet var window: NSWindow! - + @IBOutlet weak var accountMenu: NSMenu! + + let onSync = PassthroughSubject() + + private let syncLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Sync") + private var authSession: ASWebAuthenticationSession? + private var syncTotal = 0 + + func applicationWillFinishLaunching(_ notification: Notification) { + DatabaseController.shared.initialize() + + updateAccountMenu() + } func applicationDidFinishLaunching(_ aNotification: Notification) { - // Insert code here to initialize your application + syncStatuses() } func applicationWillTerminate(_ aNotification: Notification) { - // Insert code here to tear down your application + DatabaseController.shared.close() } func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } + + private func updateAccountMenu() { + accountMenu.removeAllItems() + if let account = LocalData.account { + let item = accountMenu.addItem(withTitle: "Logged in to \(account.instanceURL.host!)", action: nil, keyEquivalent: "") + item.isEnabled = false + accountMenu.addItem(withTitle: "Log out", action: #selector(logOut), keyEquivalent: "") + } else { + accountMenu.addItem(withTitle: "Log in...", action: #selector(logIn), keyEquivalent: "") + } + } + + private func syncStatuses() { + DatabaseController.shared.getNewestStatus { status in + guard let status = status else { + return + } + self.syncLogger.log("Starting sync...") + self.syncTotal = 0 + self.syncStatuses(range: .after(status.id)) + } + } + + private func syncStatuses(range: APIController.RequestRange) { + APIController.shared.getStatuses(range: range) { response in + switch response { + case .failure(let error): + self.syncLogger.error("Erorr syncing statuses: \(String(describing: error), privacy: .public)") + DispatchQueue.main.async { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Error syncing statuses" + alert.informativeText = error.localizedDescription + alert.runModal() + } + + case .success(let statuses): + guard statuses.count > 0 else { + DispatchQueue.main.async { + self.syncLogger.log("Finished sync of \(self.syncTotal, privacy: .public) statuses") + self.onSync.send() + } + return + } + + DatabaseController.shared.addStatuses(statuses.compactMap { + if $0.hasReblog { + return nil + } else { + return Status(id: $0.id, url: $0.url, summary: $0.spoiler_text, content: $0.content, published: $0.created_at) + } + }) + + self.syncTotal += statuses.count + + self.syncStatuses(range: .after(statuses.first!.id)) + } + } + } + @IBAction func importFile(_ sender: Any) { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.commaSeparatedText] + panel.beginSheetModal(for: NSApp.mainWindow!) { (resp) in + guard resp == .OK else { + return + } + ImportController.shared.importCSV(url: panel.url!) + self.onSync.send() + self.syncStatuses() + } + } + + @objc func logIn() { + let alert = NSAlert() + alert.messageText = "Enter instance URL:" + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24)) + textField.placeholderString = "https://mastodon.social/" + alert.accessoryView = textField + + guard alert.runModal() == .alertFirstButtonReturn, + let url = URL(string: textField.stringValue) else { + return + } + + LocalData.account = LocalData.AccountInfo(instanceURL: url, clientID: nil, clientSecret: nil, accessToken: nil) + + APIController.shared.register { response in + guard case .success(let registration) = response else { + fatalError() + } + + LocalData.account!.clientID = registration.client_id + LocalData.account!.clientSecret = registration.client_secret + + var authorizeComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)! + authorizeComponents.path = "/oauth/authorize" + authorizeComponents.queryItems = [ + URLQueryItem(name: "client_id", value: LocalData.account!.clientID), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: APIController.shared.scopes), + URLQueryItem(name: "redirect_uri", value: APIController.shared.redirectURI), + ] + + self.authSession = ASWebAuthenticationSession(url: authorizeComponents.url!, callbackURLScheme: "mastosearch", completionHandler: { url, error in + guard error == nil, + let url = url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let item = components.queryItems?.first(where: { $0.name == "code" }), + let authCode = item.value else { + fatalError() + } + + APIController.shared.getAccessToken(authCode: authCode) { response in + guard case .success(let settings) = response else { + fatalError() + } + + LocalData.account!.accessToken = settings.access_token + + DispatchQueue.main.async { + self.updateAccountMenu() + self.syncStatuses() + } + } + }) + DispatchQueue.main.async { + self.authSession!.presentationContextProvider = self + self.authSession!.start() + } + } + + } + + @objc func logOut() { + LocalData.account = nil + updateAccountMenu() + } + } +extension AppDelegate: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return NSApp.keyWindow! + } +} diff --git a/MastoSearch/Base.lproj/MainMenu.xib b/MastoSearch/Base.lproj/MainMenu.xib deleted file mode 100644 index 56c05ba..0000000 --- a/MastoSearch/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,695 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Default - - - - - - - Left to Right - - - - - - - Right to Left - - - - - - - - - - - Default - - - - - - - Left to Right - - - - - - - Right to Left - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/MastoSearch/DatabaseController.swift b/MastoSearch/DatabaseController.swift new file mode 100644 index 0000000..464bddc --- /dev/null +++ b/MastoSearch/DatabaseController.swift @@ -0,0 +1,196 @@ +// +// DatabaseController.swift +// MastoSearch +// +// Created by Shadowfacts on 12/10/21. +// + +import Foundation +import FMDB +import OSLog +import Combine + +class DatabaseController { + + static let shared = DatabaseController() + + private let log = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DatabaseController") + static let dateFormat = Date.ISO8601FormatStyle(includingFractionalSeconds: true) + + private let applicationSupport: URL + private let databaseURL: URL + + private var queue: FMDatabaseQueue! + + private(set) var isInitialized = false + let onInitialize = PassthroughSubject<(), Never>() + + private init() { + // this dir will be inside the application sandbox container + applicationSupport = try! FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + databaseURL = applicationSupport.appendingPathComponent("statuses").appendingPathExtension("sqlite") + } + + func initialize() { + if !FileManager.default.fileExists(atPath: databaseURL.absoluteString) { + FileManager.default.createFile(atPath: databaseURL.absoluteString, contents: nil, attributes: nil) + } + queue = FMDatabaseQueue(path: databaseURL.absoluteString) + queue.inDatabase { db in + let success = db.executeStatements(""" + CREATE TABLE IF NOT EXISTS statuses ( + id INTEGER PRIMARY KEY, + api_id TEXT, + url TEXT, + summary TEXT, + status_content TEXT NOT NULL, + published TEXT NOT NULL + ); + + CREATE VIRTUAL TABLE IF NOT EXISTS statuses_fts USING fts5( + summary, + status_content, + url UNINDEXED, + published UNINDEXED, + api_id UNINDEXED, + content = 'statuses', + content_rowid = 'id', + tokenize = "porter unicode61 tokenchars '@-_'" + ); + + CREATE TRIGGER IF NOT EXISTS statuses_ai AFTER INSERT ON statuses BEGIN + INSERT INTO statuses_fts(rowid, summary, status_content) VALUES (new.id, new.summary, new.status_content); + END; + CREATE TRIGGER IF NOT EXISTS statuses_ad AFTER DELETE ON statuses BEGIN + INSERT INTO statuses_fts(statuses_fts, rowid, summary, status_content) VALUES('delete', old.id, old.summary, old.status_content); + END; + CREATE TRIGGER IF NOT EXISTS statuses_au AFTER UPDATE ON statuses BEGIN + INSERT INTO statuses_fts(statuses_fts, rowid, summary, status_content) VALUES('delete', old.id, old.summary, new.status_content); + INSERT INTO statuses_fts(rowid, summary, status_content) VALUES (new.id, new.summary, new.status_content); + END; + """) + guard success else { + fatalError("failed to create schema: \(db.lastError())") + } + log.info("Setup schema") + + } + isInitialized = true + // this is safe, FMDatabaseQueue calls are blocking + onInitialize.send() + } + + func close() { + // db.close() +// log.info("Closed database") + } + + func addStatuses(_ statuses: S) where S.Element == Status { + queue.inTransaction { db, rollback in + var i = 0 + for status in statuses { + do { + let summary: AnyObject + if let s = status.summary { + summary = s as NSString + } else { + summary = NSNull() + } + try db.executeUpdate("INSERT INTO statuses (api_id, url, summary, status_content, published) VALUES (?, ?, ?, ?, ?);", values: [ + status.id as NSString, + status.url as NSString, + summary, + status.content as NSString, + DatabaseController.dateFormat.format(status.published) as NSString + ]) + } catch { + log.error("failed to insert status: \(error.localizedDescription, privacy: .public)") + rollback.pointee = true + return + } + i += 1 + if i % 100 == 0 { + log.debug("Imported \(i, privacy: .public) statuses...") + } + } + log.info("Finished import of \(i, privacy: .public) statuses") + } + } + + func getStatuses(sortDescriptor: NSSortDescriptor?, completion: @escaping (StatusSequence) -> Void) { + queue.inDatabase { db in + let sortKey = sortDescriptor?.key ?? "published" + let asc = sortDescriptor?.ascending == true ? "ASC" : "DESC" + let results = try! db.executeQuery("SELECT * FROM statuses ORDER BY \(sortKey) \(asc)", values: nil) + let sequence = StatusSequence(results: results) + completion(sequence) + } + } + + func getStatuses(query: String, sortDescriptor: NSSortDescriptor?, completion: @escaping (StatusSequence) -> Void) { + queue.inDatabase { db in + let sortKey = sortDescriptor?.key ?? "rank" + let asc = sortDescriptor?.ascending == false ? "DESC" : "ASC" + let results = try! db.executeQuery("SELECT * FROM statuses_fts WHERE statuses_fts match ? ORDER BY \(sortKey) \(asc)", values: [query as NSString]) + let sequence = StatusSequence(results: results) + completion(sequence) + } + } + + func getNewestStatus(completion: @escaping (Status?) -> Void) { + queue.inDatabase { db in + let results = try! db.executeQuery("SELECT * FROM statuses ORDER BY published DESC LIMIT 1", values: nil) + completion(StatusSequence(results: results).makeIterator().next()) + } + } + + func countStatuses() -> Int { + var res: Int! + queue.inDatabase { db in + let results = try! db.executeQuery("SELECT COUNT(*) AS count FROM statuses", values: nil) + results.next() + res = Int(results.int(forColumn: "count")) + } + return res + } + +} + +struct StatusSequence: Sequence { + typealias Element = Status + + let results: FMResultSet + + func makeIterator() -> Iterator { + return Iterator(results: results) + } + + class Iterator: IteratorProtocol { + typealias Element = Status + + let results: FMResultSet + + init(results: FMResultSet) { + self.results = results + } + + deinit { + results.close() + } + + func next() -> Status? { + if results.next() { + return Status( + id: results.string(forColumn: "api_id")!, + url: results.string(forColumn: "url")!, + summary: results.string(forColumn: "summary"), + content: results.string(forColumn: "status_content")!, + published: try! DatabaseController.dateFormat.parse(results.string(forColumn: "published")!) + ) + } + return nil + } + } + +} + diff --git a/MastoSearch/ImportController.swift b/MastoSearch/ImportController.swift new file mode 100644 index 0000000..7f075ed --- /dev/null +++ b/MastoSearch/ImportController.swift @@ -0,0 +1,112 @@ +// +// ImportController.swift +// MastoSearch +// +// Created by Shadowfacts on 12/10/21. +// + +import Foundation +import TabularData +import Accelerate +import OSLog + +/* + + imports from pleroma csv dumps generated with the following psql command: + + \copy (select a.id, a.data as activity_data, o.data as object_data from activities as a left join objects as o on o.data->>'id' = a.data->>'object' where a.data->>'actor'='https://social.shadowfacts.net/users/shadowfacts' and a.data->>'type'='Create' and (o.data->>'type'='Note' or a.data->'object'->>'type'='Note')) to '/home/pleroma/shadowfacts.csv' csv header; + + */ + +class ImportController { + static let shared = ImportController() + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ImportController") + private let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ" + f.timeZone = TimeZone(abbreviation: "UTC") + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + private init() {} + + func importCSV(url: URL) { + var opts = CSVReadingOptions() + opts.usesQuoting = true + opts.addDateParseStrategy(Date.ISO8601FormatStyle(includingFractionalSeconds: true)) + let dataFrame = try! DataFrame(contentsOfCSVFile: url, columns: ["id", "activity_data", "object_data"], types: [ + "id": .string, + "activity_data": .data, + "object_data": .data, + ], options: opts) + let statuses = dataFrame.rows.lazy.enumerated().compactMap { (index, row) -> Status? in + if index % 100 == 0 { + logger.debug("Parsing row \(index, privacy: .public)") + } + + let uuid = row["id"] as! String + let activityData = row["activity_data"] as! Data + let activity = try! JSONSerialization.jsonObject(with: activityData, options: []) as! [String: Any] + + let object: [String: Any] + if let objectData = row["object_data"] as? Data { + object = try! JSONSerialization.jsonObject(with: objectData, options: []) as! [String: Any] + } else { + object = activity["object"] as! [String: Any] + } + + let id = uuidToFlakeIdStr(uuid) + let url = activity["id"] as! String + var summary = object["summary"] as? String + if let s = summary, s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + summary = nil + } + let content = object["content"] as! String + let published = self.dateFormatter.date(from: activity["published"] as! String)! + + return Status(id: id, url: url, summary: summary, content: content, published: published) + } + DatabaseController.shared.addStatuses(statuses) + } + + // https://git.pleroma.social/pleroma/elixir-libraries/flake_id/-/blob/master/lib/flake_id/ecto/compat_type.ex + func uuidToFlakeIdStr(_ uuidStr: String) -> String { + let uuid = UUID(uuidString: uuidStr)! + + var bytes = [UInt8](repeating: 0, count: 16) + bytes.withUnsafeMutableBufferPointer { buffer in + (uuid as NSUUID).getBytes(buffer.baseAddress!) + } + + let num = bytes.withUnsafeBytes { raw -> UInt128 in + let uint64s = raw.bindMemory(to: UInt64.self) + return UInt128(upperBits: UInt64(bigEndian: uint64s[0]), lowerBits: UInt64(bigEndian: uint64s[1])) + } + + if num.leadingZeroBitCount >= 64 { + return num.description + } else { + return encodeBase62(num) + } + } + + private let base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + func encodeBase62(_ value: UInt128) -> String { + var s = "" + var cur = value + + while cur != .zero { + let (q, r) = cur.quotientAndRemainder(dividingBy: UInt128(base62Alphabet.count)) + cur = q + let index = base62Alphabet.index(base62Alphabet.startIndex, offsetBy: Int(r)) + let c = base62Alphabet[index] + s = String(c) + s + } + + return s + } + +} diff --git a/MastoSearch/LocalData.swift b/MastoSearch/LocalData.swift new file mode 100644 index 0000000..6aaaabe --- /dev/null +++ b/MastoSearch/LocalData.swift @@ -0,0 +1,40 @@ +// +// LocalData.swift +// MastoSearch +// +// Created by Shadowfacts on 12/15/21. +// + +import Foundation + +class LocalData { + private init() {} + + private static let encoder = PropertyListEncoder() + private static let decoder = PropertyListDecoder() + + static var account: AccountInfo? { + get { + guard let data = UserDefaults.standard.data(forKey: "account") else { + return nil + } + return try? decoder.decode(AccountInfo.self, from: data) + } + set { + guard let newValue = newValue else { + UserDefaults.standard.set(nil, forKey: "account") + return + } + let data = try! encoder.encode(newValue) + UserDefaults.standard.set(data, forKey: "account") + } + } + + struct AccountInfo: Codable { + let instanceURL: URL + var clientID: String! + var clientSecret: String! + var accessToken: String! + } + +} diff --git a/MastoSearch/Main.storyboard b/MastoSearch/Main.storyboard new file mode 100644 index 0000000..e476b19 --- /dev/null +++ b/MastoSearch/Main.storyboard @@ -0,0 +1,862 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MastoSearch/MastoSearch.entitlements b/MastoSearch/MastoSearch.entitlements index f2ef3ae..625af03 100644 --- a/MastoSearch/MastoSearch.entitlements +++ b/MastoSearch/MastoSearch.entitlements @@ -2,9 +2,11 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + diff --git a/MastoSearch/Status.swift b/MastoSearch/Status.swift new file mode 100644 index 0000000..9823dd6 --- /dev/null +++ b/MastoSearch/Status.swift @@ -0,0 +1,16 @@ +// +// Status.swift +// MastoSearch +// +// Created by Shadowfacts on 12/10/21. +// + +import Foundation + +struct Status: Identifiable { + let id: String + let url: String + let summary: String? + let content: String + let published: Date +} diff --git a/MastoSearch/Vendor/UInt128.swift b/MastoSearch/Vendor/UInt128.swift new file mode 100644 index 0000000..5376965 --- /dev/null +++ b/MastoSearch/Vendor/UInt128.swift @@ -0,0 +1,751 @@ +// from https://github.com/Jitsusama/UInt128/blob/master/Sources/UInt128.swift +// +// UInt128.swift +// +// An implementation of a 128-bit unsigned integer data type not +// relying on any outside libraries apart from Swift's standard +// library. It also seeks to implement the entirety of the +// UnsignedInteger protocol as well as standard functions supported +// by Swift's native unsigned integer types. +// +// Copyright 2017 Joel Gerber +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// MARK: Error Type + +/// An `ErrorType` for `UInt128` data types. It includes cases +/// for errors that can occur during string +/// conversion. +public enum UInt128Errors : Error { + /// Input cannot be converted to a UInt128 value. + case invalidString +} + +// MARK: - Data Type + +/// A 128-bit unsigned integer value type. +/// Storage is based upon a tuple of 2, 64-bit, unsigned integers. +public struct UInt128 { + // MARK: Instance Properties + + /// Internal value is presented as a tuple of 2 64-bit + /// unsigned integers. + internal var value: (upperBits: UInt64, lowerBits: UInt64) + + /// Counts up the significant bits in stored data. + public var significantBits: UInt128 { + return UInt128(UInt128.bitWidth - leadingZeroBitCount) + } + + /// Undocumented private variable required for passing this type + /// to a BinaryFloatingPoint type. See FloatingPoint.swift.gyb in + /// the Swift stdlib/public/core directory. + internal var signBitIndex: Int { + return 127 - leadingZeroBitCount + } + + // MARK: Initializers + + /// Designated initializer for the UInt128 type. + public init(upperBits: UInt64, lowerBits: UInt64) { + value.upperBits = upperBits + value.lowerBits = lowerBits + } + + public init() { + self.init(upperBits: 0, lowerBits: 0) + } + + public init(_ source: UInt128) { + self.init(upperBits: source.value.upperBits, + lowerBits: source.value.lowerBits) + } + + /// Initialize a UInt128 value from a string. + /// + /// - parameter source: the string that will be converted into a + /// UInt128 value. Defaults to being analyzed as a base10 number, + /// but can be prefixed with `0b` for base2, `0o` for base8 + /// or `0x` for base16. + public init(_ source: String) throws { + guard let result = UInt128._valueFromString(source) else { + throw UInt128Errors.invalidString + } + self = result + } +} + +// MARK: - FixedWidthInteger Conformance + +extension UInt128 : FixedWidthInteger { + // MARK: Instance Properties + + public var nonzeroBitCount: Int { + return value.lowerBits.nonzeroBitCount + value.upperBits.nonzeroBitCount + } + + public var leadingZeroBitCount: Int { + if value.upperBits == 0 { + return UInt64.bitWidth + value.lowerBits.leadingZeroBitCount + } + return value.upperBits.leadingZeroBitCount + } + + /// Returns the big-endian representation of the integer, changing the byte order if necessary. + public var bigEndian: UInt128 { + #if arch(i386) || arch(x86_64) || arch(arm) || arch(arm64) + return self.byteSwapped + #else + return self + #endif + } + + /// Returns the little-endian representation of the integer, changing the byte order if necessary. + public var littleEndian: UInt128 { + #if arch(i386) || arch(x86_64) || arch(arm) || arch(arm64) + return self + #else + return self.byteSwapped + #endif + } + + /// Returns the current integer with the byte order swapped. + public var byteSwapped: UInt128 { + return UInt128(upperBits: self.value.lowerBits.byteSwapped, + lowerBits: self.value.upperBits.byteSwapped) + } + + // MARK: Initializers + + /// Creates a UInt128 from a given value, with the input's value + /// truncated to a size no larger than what UInt128 can handle. + /// Since the input is constrained to an UInt, no truncation needs + /// to occur, as a UInt is currently 64 bits at the maximum. + public init(_truncatingBits bits: UInt) { + self.init(upperBits: 0, lowerBits: UInt64(bits)) + } + + /// Creates an integer from its big-endian representation, changing the + /// byte order if necessary. + public init(bigEndian value: UInt128) { + self = value.bigEndian + } + + /// Creates an integer from its little-endian representation, changing the + /// byte order if necessary. + public init(littleEndian value: UInt128) { + self = value.littleEndian + } + + // MARK: Instance Methods + + public func addingReportingOverflow(_ rhs: UInt128) -> (partialValue: UInt128, overflow: Bool) { + var resultOverflow = false + let (lowerBits, lowerOverflow) = self.value.lowerBits.addingReportingOverflow(rhs.value.lowerBits) + var (upperBits, upperOverflow) = self.value.upperBits.addingReportingOverflow(rhs.value.upperBits) + + // If the lower bits overflowed, we need to add 1 to upper bits. + if lowerOverflow { + (upperBits, resultOverflow) = upperBits.addingReportingOverflow(1) + } + + return (partialValue: UInt128(upperBits: upperBits, lowerBits: lowerBits), + overflow: upperOverflow || resultOverflow) + } + + public func subtractingReportingOverflow(_ rhs: UInt128) -> (partialValue: UInt128, overflow: Bool) { + var resultOverflow = false + let (lowerBits, lowerOverflow) = self.value.lowerBits.subtractingReportingOverflow(rhs.value.lowerBits) + var (upperBits, upperOverflow) = self.value.upperBits.subtractingReportingOverflow(rhs.value.upperBits) + + // If the lower bits overflowed, we need to subtract (borrow) 1 from the upper bits. + if lowerOverflow { + (upperBits, resultOverflow) = upperBits.subtractingReportingOverflow(1) + } + + return (partialValue: UInt128(upperBits: upperBits, lowerBits: lowerBits), + overflow: upperOverflow || resultOverflow) + } + + public func multipliedReportingOverflow(by rhs: UInt128) -> (partialValue: UInt128, overflow: Bool) { + let multiplicationResult = self.multipliedFullWidth(by: rhs) + let overflowEncountered = multiplicationResult.high > 0 + + return (partialValue: multiplicationResult.low, + overflow: overflowEncountered) + } + + public func multipliedFullWidth(by other: UInt128) -> (high: UInt128, low: UInt128.Magnitude) { + // Bit mask that facilitates masking the lower 32 bits of a 64 bit UInt. + let lower32 = UInt64(UInt32.max) + + // Decompose lhs into an array of 4, 32 significant bit UInt64s. + let lhsArray = [ + self.value.upperBits >> 32, /*0*/ self.value.upperBits & lower32, /*1*/ + self.value.lowerBits >> 32, /*2*/ self.value.lowerBits & lower32 /*3*/ + ] + + // Decompose rhs into an array of 4, 32 significant bit UInt64s. + let rhsArray = [ + other.value.upperBits >> 32, /*0*/ other.value.upperBits & lower32, /*1*/ + other.value.lowerBits >> 32, /*2*/ other.value.lowerBits & lower32 /*3*/ + ] + + // The future contents of this array will be used to store segment + // multiplication results. + var resultArray = [[UInt64]]( + repeating: [UInt64](repeating: 0, count: 4), count: 4 + ) + + // Loop through every combination of lhsArray[x] * rhsArray[y] + for rhsSegment in 0 ..< rhsArray.count { + for lhsSegment in 0 ..< lhsArray.count { + let currentValue = lhsArray[lhsSegment] * rhsArray[rhsSegment] + resultArray[lhsSegment][rhsSegment] = currentValue + } + } + + // Perform multiplication similar to pen and paper in 64bit, 32bit masked increments. + let bitSegment8 = resultArray[3][3] & lower32 + let bitSegment7 = UInt128._variadicAdditionWithOverflowCount( + resultArray[2][3] & lower32, + resultArray[3][2] & lower32, + resultArray[3][3] >> 32) // overflow from bitSegment8 + let bitSegment6 = UInt128._variadicAdditionWithOverflowCount( + resultArray[1][3] & lower32, + resultArray[2][2] & lower32, + resultArray[3][1] & lower32, + resultArray[2][3] >> 32, // overflow from bitSegment7 + resultArray[3][2] >> 32, // overflow from bitSegment7 + bitSegment7.overflowCount) + let bitSegment5 = UInt128._variadicAdditionWithOverflowCount( + resultArray[0][3] & lower32, + resultArray[1][2] & lower32, + resultArray[2][1] & lower32, + resultArray[3][0] & lower32, + resultArray[1][3] >> 32, // overflow from bitSegment6 + resultArray[2][2] >> 32, // overflow from bitSegment6 + resultArray[3][1] >> 32, // overflow from bitSegment6 + bitSegment6.overflowCount) + let bitSegment4 = UInt128._variadicAdditionWithOverflowCount( + resultArray[0][2] & lower32, + resultArray[1][1] & lower32, + resultArray[2][0] & lower32, + resultArray[0][3] >> 32, // overflow from bitSegment5 + resultArray[1][2] >> 32, // overflow from bitSegment5 + resultArray[2][1] >> 32, // overflow from bitSegment5 + resultArray[3][0] >> 32, // overflow from bitSegment5 + bitSegment5.overflowCount) + let bitSegment3 = UInt128._variadicAdditionWithOverflowCount( + resultArray[0][1] & lower32, + resultArray[1][0] & lower32, + resultArray[0][2] >> 32, // overflow from bitSegment4 + resultArray[1][1] >> 32, // overflow from bitSegment4 + resultArray[2][0] >> 32, // overflow from bitSegment4 + bitSegment4.overflowCount) + let bitSegment1 = UInt128._variadicAdditionWithOverflowCount( + resultArray[0][0], + resultArray[0][1] >> 32, // overflow from bitSegment3 + resultArray[1][0] >> 32, // overflow from bitSegment3 + bitSegment3.overflowCount) + + // Shift and merge the results into 64 bit groups, adding in overflows as we go. + let lowerLowerBits = UInt128._variadicAdditionWithOverflowCount( + bitSegment8, + bitSegment7.truncatedValue << 32) + let upperLowerBits = UInt128._variadicAdditionWithOverflowCount( + bitSegment7.truncatedValue >> 32, + bitSegment6.truncatedValue, + bitSegment5.truncatedValue << 32, + lowerLowerBits.overflowCount) + let lowerUpperBits = UInt128._variadicAdditionWithOverflowCount( + bitSegment5.truncatedValue >> 32, + bitSegment4.truncatedValue, + bitSegment3.truncatedValue << 32, + upperLowerBits.overflowCount) + let upperUpperBits = UInt128._variadicAdditionWithOverflowCount( + bitSegment3.truncatedValue >> 32, + bitSegment1.truncatedValue, + lowerUpperBits.overflowCount) + + // Bring the 64bit unsigned integer results together into a high and low 128bit unsigned integer result. + return (high: UInt128(upperBits: upperUpperBits.truncatedValue, lowerBits: lowerUpperBits.truncatedValue), + low: UInt128(upperBits: upperLowerBits.truncatedValue, lowerBits: lowerLowerBits.truncatedValue)) + } + + /// Takes a variable amount of 64bit Unsigned Integers and adds them together, + /// tracking the total amount of overflows that occurred during addition. + /// + /// - Parameter addends: + /// Variably sized list of UInt64 values. + /// - Returns: + /// A tuple containing the truncated result and a count of the total + /// amount of overflows that occurred during addition. + private static func _variadicAdditionWithOverflowCount(_ addends: UInt64...) -> (truncatedValue: UInt64, overflowCount: UInt64) { + var sum: UInt64 = 0 + var overflowCount: UInt64 = 0 + + addends.forEach { addend in + let interimSum = sum.addingReportingOverflow(addend) + if interimSum.overflow { + overflowCount += 1 + } + sum = interimSum.partialValue + } + + return (truncatedValue: sum, overflowCount: overflowCount) + } + + public func dividedReportingOverflow(by rhs: UInt128) -> (partialValue: UInt128, overflow: Bool) { + guard rhs != 0 else { + return (self, true) + } + + let quotient = self.quotientAndRemainder(dividingBy: rhs).quotient + return (quotient, false) + } + + public func dividingFullWidth(_ dividend: (high: UInt128, low: UInt128)) -> (quotient: UInt128, remainder: UInt128) { + return self._quotientAndRemainderFullWidth(dividingBy: dividend) + } + + public func remainderReportingOverflow(dividingBy rhs: UInt128) -> (partialValue: UInt128, overflow: Bool) { + guard rhs != 0 else { + return (self, true) + } + + let remainder = self.quotientAndRemainder(dividingBy: rhs).remainder + return (remainder, false) + } + + public func quotientAndRemainder(dividingBy rhs: UInt128) -> (quotient: UInt128, remainder: UInt128) { + return rhs._quotientAndRemainderFullWidth(dividingBy: (high: 0, low: self)) + } + + /// Provides the quotient and remainder when dividing the provided value by self. + internal func _quotientAndRemainderFullWidth(dividingBy dividend: (high: UInt128, low: UInt128)) -> (quotient: UInt128, remainder: UInt128) { + let divisor = self + let numeratorBitsToWalk: UInt128 + + if dividend.high > 0 { + numeratorBitsToWalk = dividend.high.significantBits + 128 - 1 + } else if dividend.low == 0 { + return (0, 0) + } else { + numeratorBitsToWalk = dividend.low.significantBits - 1 + } + + // The below algorithm was adapted from: + // https://en.wikipedia.org/wiki/Division_algorithm#Integer_division_.28unsigned.29_with_remainder + + precondition(self != 0, "Division by 0") + + var quotient = UInt128.min + var remainder = UInt128.min + + for numeratorShiftWidth in (0...numeratorBitsToWalk).reversed() { + remainder <<= 1 + remainder |= UInt128._bitFromDoubleWidth(at: numeratorShiftWidth, for: dividend) + + if remainder >= divisor { + remainder -= divisor + quotient |= 1 << numeratorShiftWidth + } + } + + return (quotient, remainder) + } + + /// Returns the bit stored at the given position for the provided double width UInt128 input. + /// + /// - parameter at: position to grab bit value from. + /// - parameter for: the double width UInt128 data value to grab the + /// bit from. + /// - returns: single bit stored in a UInt128 value. + internal static func _bitFromDoubleWidth(at bitPosition: UInt128, for input: (high: UInt128, low: UInt128)) -> UInt128 { + switch bitPosition { + case 0: + return input.low & 1 + case 1...127: + return input.low >> bitPosition & 1 + case 128: + return input.high & 1 + default: + return input.high >> (bitPosition - 128) & 1 + } + } +} + +// MARK: - BinaryInteger Conformance + +extension UInt128 { + // MARK: Instance Properties + + public static var bitWidth : Int { return 128 } +} + +extension UInt128 : BinaryInteger { + // MARK: Instance Methods + + public var words: [UInt] { + return Array(value.lowerBits.words) + Array(value.upperBits.words) + } + + public var trailingZeroBitCount: Int { + if value.lowerBits == 0 { + return UInt64.bitWidth + value.upperBits.trailingZeroBitCount + } + return value.lowerBits.trailingZeroBitCount + } + + // MARK: Initializers + + public init?(exactly source: T) { + if source.isZero { + self = UInt128() + } + else if source.exponent < 0 || source.rounded() != source { + return nil + } + else { + self = UInt128(UInt64(source)) + } + } + + public init(_ source: T) { + self.init(UInt64(source)) + } + + // MARK: Type Methods + + public static func /(lhs: UInt128, rhs: UInt128) -> UInt128 { + let result = lhs.dividedReportingOverflow(by: rhs) + + return result.partialValue + } + + public static func /=(lhs: inout UInt128, rhs: UInt128) { + lhs = lhs / rhs + } + + public static func %(lhs: UInt128, rhs: UInt128) -> UInt128 { + let result = lhs.remainderReportingOverflow(dividingBy: rhs) + + return result.partialValue + } + + public static func %=(lhs: inout UInt128, rhs: UInt128) { + lhs = lhs % rhs + } + + /// Performs a bitwise AND operation on 2 UInt128 data types. + public static func &=(lhs: inout UInt128, rhs: UInt128) { + let upperBits = lhs.value.upperBits & rhs.value.upperBits + let lowerBits = lhs.value.lowerBits & rhs.value.lowerBits + + lhs = UInt128(upperBits: upperBits, lowerBits: lowerBits) + } + + /// Performs a bitwise OR operation on 2 UInt128 data types. + public static func |=(lhs: inout UInt128, rhs: UInt128) { + let upperBits = lhs.value.upperBits | rhs.value.upperBits + let lowerBits = lhs.value.lowerBits | rhs.value.lowerBits + + lhs = UInt128(upperBits: upperBits, lowerBits: lowerBits) + } + + /// Performs a bitwise XOR operation on 2 UInt128 data types. + public static func ^=(lhs: inout UInt128, rhs: UInt128) { + let upperBits = lhs.value.upperBits ^ rhs.value.upperBits + let lowerBits = lhs.value.lowerBits ^ rhs.value.lowerBits + + lhs = UInt128(upperBits: upperBits, lowerBits: lowerBits) + } + + /// Perform a masked right SHIFT operation self. + /// + /// The masking operation will mask `rhs` against the highest + /// shift value that will not cause an overflowing shift before + /// performing the shift. IE: `rhs = 128` will become `rhs = 0` + /// and `rhs = 129` will become `rhs = 1`. + public static func &>>=(lhs: inout UInt128, rhs: UInt128) { + let shiftWidth = rhs.value.lowerBits & 127 + + switch shiftWidth { + case 0: return // Do nothing shift. + case 1...63: + let upperBits = lhs.value.upperBits >> shiftWidth + let lowerBits = (lhs.value.lowerBits >> shiftWidth) + (lhs.value.upperBits << (64 - shiftWidth)) + lhs = UInt128(upperBits: upperBits, lowerBits: lowerBits) + case 64: + // Shift 64 means move upper bits to lower bits. + lhs = UInt128(upperBits: 0, lowerBits: lhs.value.upperBits) + default: + let lowerBits = lhs.value.upperBits >> (shiftWidth - 64) + lhs = UInt128(upperBits: 0, lowerBits: lowerBits) + } + } + + /// Perform a masked left SHIFT operation on self. + /// + /// The masking operation will mask `rhs` against the highest + /// shift value that will not cause an overflowing shift before + /// performing the shift. IE: `rhs = 128` will become `rhs = 0` + /// and `rhs = 129` will become `rhs = 1`. + public static func &<<=(lhs: inout UInt128, rhs: UInt128) { + let shiftWidth = rhs.value.lowerBits & 127 + + switch shiftWidth { + case 0: return // Do nothing shift. + case 1...63: + let upperBits = (lhs.value.upperBits << shiftWidth) + (lhs.value.lowerBits >> (64 - shiftWidth)) + let lowerBits = lhs.value.lowerBits << shiftWidth + lhs = UInt128(upperBits: upperBits, lowerBits: lowerBits) + case 64: + // Shift 64 means move lower bits to upper bits. + lhs = UInt128(upperBits: lhs.value.lowerBits, lowerBits: 0) + default: + let upperBits = lhs.value.lowerBits << (shiftWidth - 64) + lhs = UInt128(upperBits: upperBits, lowerBits: 0) + } + } +} + +// MARK: - UnsignedInteger Conformance + +extension UInt128 : UnsignedInteger {} + +// MARK: - Hashable Conformance + +extension UInt128 : Hashable { + public var hashValue: Int { + return self.value.lowerBits.hashValue ^ self.value.upperBits.hashValue + } +} + +// MARK: - Numeric Conformance + +extension UInt128 : Numeric { + public static func +(lhs: UInt128, rhs: UInt128) -> UInt128 { + precondition(~lhs >= rhs, "Addition overflow!") + let result = lhs.addingReportingOverflow(rhs) + return result.partialValue + } + public static func +=(lhs: inout UInt128, rhs: UInt128) { + lhs = lhs + rhs + } + public static func -(lhs: UInt128, rhs: UInt128) -> UInt128 { + precondition(lhs >= rhs, "Integer underflow") + let result = lhs.subtractingReportingOverflow(rhs) + return result.partialValue + } + public static func -=(lhs: inout UInt128, rhs: UInt128) { + lhs = lhs - rhs + } + public static func *(lhs: UInt128, rhs: UInt128) -> UInt128 { + let result = lhs.multipliedReportingOverflow(by: rhs) + precondition(!result.overflow, "Multiplication overflow!") + return result.partialValue + } + public static func *=(lhs: inout UInt128, rhs: UInt128) { + lhs = lhs * rhs + } +} + +// MARK: - Equatable Conformance + +extension UInt128 : Equatable { + /// Checks if the `lhs` is equal to the `rhs`. + public static func ==(lhs: UInt128, rhs: UInt128) -> Bool { + if lhs.value.lowerBits == rhs.value.lowerBits && lhs.value.upperBits == rhs.value.upperBits { + return true + } + return false + } +} + +// MARK: - ExpressibleByIntegerLiteral Conformance + +extension UInt128 : ExpressibleByIntegerLiteral { + public init(integerLiteral value: IntegerLiteralType) { + self.init(upperBits: 0, lowerBits: UInt64(value)) + } +} + +// MARK: - CustomStringConvertible Conformance + +extension UInt128 : CustomStringConvertible { + // MARK: Instance Properties + + public var description: String { + return self._valueToString() + } + + // MARK: Instance Methods + + /// Converts the stored value into a string representation. + /// - parameter radix: + /// The radix for the base numbering system you wish to have + /// the type presented in. + /// - parameter uppercase: + /// Determines whether letter components of the outputted string will be in + /// uppercase format or not. + /// - returns: + /// String representation of the stored UInt128 value. + internal func _valueToString(radix: Int = 10, uppercase: Bool = true) -> String { + precondition((2...36) ~= radix, "radix must be within the range of 2-36.") + // Simple case. + if self == 0 { + return "0" + } + + // Will store the final string result. + var result = String() + + // Used as the check for indexing through UInt128 for string interpolation. + var divmodResult = (quotient: self, remainder: UInt128(0)) + // Will hold the pool of possible values. + let characterPool = uppercase ? "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" : "0123456789abcdefghijklmnopqrstuvwxyz" + // Go through internal value until every base position is string(ed). + repeat { + divmodResult = divmodResult.quotient.quotientAndRemainder(dividingBy: UInt128(radix)) + let index = characterPool.index(characterPool.startIndex, offsetBy: Int(divmodResult.remainder)) + result.insert(characterPool[index], at: result.startIndex) + } while divmodResult.quotient > 0 + return result + } +} + +// MARK: - CustomDebugStringConvertible Conformance + +extension UInt128 : CustomDebugStringConvertible { + public var debugDescription: String { + return self.description + } +} + +// MARK: - Comparable Conformance + +extension UInt128 : Comparable { + public static func <(lhs: UInt128, rhs: UInt128) -> Bool { + if lhs.value.upperBits < rhs.value.upperBits { + return true + } else if lhs.value.upperBits == rhs.value.upperBits && lhs.value.lowerBits < rhs.value.lowerBits { + return true + } + return false + } +} + +// MARK: - ExpressibleByStringLiteral Conformance + +extension UInt128 : ExpressibleByStringLiteral { + // MARK: Initializers + + public init(stringLiteral value: StringLiteralType) { + self.init() + + if let result = UInt128._valueFromString(value) { + self = result + } + } + + // MARK: Type Methods + + internal static func _valueFromString(_ value: String) -> UInt128? { + let radix = UInt128._determineRadixFromString(value) + let inputString = radix == 10 ? value : String(value.dropFirst(2)) + + return UInt128(inputString, radix: radix) + } + + internal static func _determineRadixFromString(_ string: String) -> Int { + switch string.prefix(2) { + case "0b": return 2 + case "0o": return 8 + case "0x": return 16 + default: return 10 + } + } +} + +// MARK: - Codable Conformance + +extension UInt128 : Codable { + private enum CodingKeys : String, CodingKey { + case upperBits = "upperBits", lowerBits = "lowerBits" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let upperBits = try container.decode(UInt64.self, forKey: .upperBits) + let lowerBits = try container.decode(UInt64.self, forKey: .lowerBits) + self.init(upperBits: upperBits, lowerBits: lowerBits) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(value.upperBits, forKey: .upperBits) + try container.encode(value.lowerBits, forKey: .lowerBits) + } +} + +// MARK: - Deprecated API + +extension UInt128 { + /// Initialize a UInt128 value from a string. + /// + /// - parameter source: the string that will be converted into a + /// UInt128 value. Defaults to being analyzed as a base10 number, + /// but can be prefixed with `0b` for base2, `0o` for base8 + /// or `0x` for base16. + @available(swift, deprecated: 3.2, renamed: "init(_:)") + public static func fromUnparsedString(_ source: String) throws -> UInt128 { + return try UInt128(source) + } +} + +// MARK: - BinaryFloatingPoint Interworking + +extension BinaryFloatingPoint { + public init(_ value: UInt128) { + precondition(value.value.upperBits == 0, "Value is too large to fit into a BinaryFloatingPoint until a 128bit BinaryFloatingPoint type is defined.") + self.init(value.value.lowerBits) + } + + public init?(exactly value: UInt128) { + if value.value.upperBits > 0 { + return nil + } + self = Self(value.value.lowerBits) + } +} + +// MARK: - String Interworking + +extension String { + /// Creates a string representing the given value in base 10, or some other + /// specified base. + /// + /// - Parameters: + /// - value: The UInt128 value to convert to a string. + /// - radix: The base to use for the string representation. `radix` must be + /// at least 2 and at most 36. The default is 10. + /// - uppercase: Pass `true` to use uppercase letters to represent numerals + /// or `false` to use lowercase letters. The default is `false`. + public init(_ value: UInt128, radix: Int = 10, uppercase: Bool = false) { + self = value._valueToString(radix: radix, uppercase: uppercase) + } +} diff --git a/MastoSearch/ViewController.swift b/MastoSearch/ViewController.swift new file mode 100644 index 0000000..76e9ff4 --- /dev/null +++ b/MastoSearch/ViewController.swift @@ -0,0 +1,208 @@ +// +// ViewController.swift +// MastoSearch +// +// Created by Shadowfacts on 12/14/21. +// + +import Cocoa +import Combine +import SwiftSoup + +class ViewController: NSViewController { + + private static let formatter: DateFormatter = { + let f = DateFormatter() + f.locale = .current + f.setLocalizedDateFormatFromTemplate("yyyy-MM-dd hh:mm a") + return f + }() + private static let searchThread = DispatchQueue(label: "Search", qos: .userInitiated) + + @IBOutlet weak var table: NSTableView! + @IBOutlet weak var progressIndicator: NSProgressIndicator! + + private var dataSource: DataSource! + private var allStatusesSnapshot: NSDiffableDataSourceSnapshot? + + private var cancellables = Set() + + private var query: String? + + override func viewDidLoad() { + super.viewDidLoad() + + dataSource = DataSource(owner: self) { tableView, tableColumn, row, item in + let cell = tableView.makeView(withIdentifier: tableColumn.identifier, owner: self) as! NSTableCellView + + switch cell.identifier! { + case .date: + cell.textField!.font = .monospacedDigitSystemFont(ofSize: 13, weight: .regular) + cell.textField!.stringValue = ViewController.formatter.string(from: item.status.published) + + case .contentWarning: + cell.textField!.stringValue = item.status.summary ?? "" + + case .content: + let doc = try! SwiftSoup.parse(item.status.content) + cell.textField!.stringValue = try! doc.text() + + default: + fatalError() + } + + return cell + } + + table.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)] + + let menu = NSMenu() + menu.addItem(withTitle: "Open in Browser", action: #selector(openURL(_:)), keyEquivalent: "") + menu.addItem(withTitle: "Copy URL", action: #selector(copyURL(_:)), keyEquivalent: "c") + table.menu = menu + + DatabaseController.shared.onInitialize + .sink { [unowned self] in self.loadAll() } + .store(in: &cancellables) + + (NSApp.delegate as! AppDelegate).onSync + .sink { [unowned self] in + self.allStatusesSnapshot = nil + self.loadAll() + } + .store(in: &cancellables) + } + + override func viewWillAppear() { + super.viewWillAppear() + + progressIndicator.startAnimation(nil) + } + + private func loadStatuses(_ statuses: StatusSequence) -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.statuses]) + snapshot.appendItems(statuses.map { Item(status: $0) }, toSection: .statuses) + DispatchQueue.main.async { + self.dataSource.apply(snapshot, animatingDifferences: false) { + self.progressIndicator.stopAnimation(nil) + } + } + return snapshot + } + + private func loadAll() { + self.query = nil + + if let snapshot = allStatusesSnapshot { + dispatchPrecondition(condition: .onQueue(.main)) + self.dataSource.apply(snapshot, animatingDifferences: false) + } else { + let sortDescriptor = table.sortDescriptors.first + ViewController.searchThread.async { + DatabaseController.shared.getStatuses(sortDescriptor: sortDescriptor) { statuses in + let snapshot = self.loadStatuses(statuses) + self.allStatusesSnapshot = snapshot + } + } + } + } + + func search(_ query: String) { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + self.query = query + guard !query.isEmpty else { + loadAll() + return + } + + let sortDescriptor = table.sortDescriptors.first + ViewController.searchThread.async { + DatabaseController.shared.getStatuses(query: query, sortDescriptor: sortDescriptor) { statuses in + _ = self.loadStatuses(statuses) + } + } + } + + func sortDescriptorsChanged() { + guard DatabaseController.shared.isInitialized else { + return + } + allStatusesSnapshot = nil + self.dataSource.apply(NSDiffableDataSourceSnapshot(), animatingDifferences: false) + if let query = query { + search(query) + } else { + loadAll() + } + } + + @objc func openURL(_ sender: Any) { + guard table.clickedRow != -1, + let item = dataSource.itemIdentifier(forRow: table.clickedRow) else { + return + } + NSWorkspace.shared.open(URL(string: item.status.url)!) + } + + @objc func copyURL(_ sender: Any) { + guard table.clickedRow != -1, + let item = dataSource.itemIdentifier(forRow: table.clickedRow) else { + return + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(item.status.url, forType: .string) + } + +} + +extension ViewController { + enum Section { + case statuses + } + struct Item: Hashable { + let status: Status + + static func ==(lhs: Item, rhs: Item) -> Bool { + return lhs.status.url == rhs.status.url + } + + func hash(into hasher: inout Hasher) { + hasher.combine(status.url) + } + } +} + +extension ViewController { + class DataSource: NSTableViewDiffableDataSource { + unowned var owner: ViewController + + init(owner: ViewController, cellProvider: @escaping NSTableViewDiffableDataSource.CellProvider) { + self.owner = owner + super.init(tableView: owner.table, cellProvider: cellProvider) + } + + // without @objc the table view doesn't detect it, even though it overrides an NSTableViewDataSource method and so should be exposed automatically + @objc func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldSortDescriptors: [NSSortDescriptor]) { + owner.sortDescriptorsChanged() + } + } +} + +extension ViewController: NSMenuItemValidation { + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + if menuItem.action == #selector(copyURL) || menuItem.action == #selector(openURL) { + return table.clickedRow != -1 + } else { + return false + } + } +} + +private extension NSUserInterfaceItemIdentifier { + + static let date = NSUserInterfaceItemIdentifier("date") + static let contentWarning = NSUserInterfaceItemIdentifier("contentWarning") + static let content = NSUserInterfaceItemIdentifier("content") + +} diff --git a/MastoSearch/WindowController.swift b/MastoSearch/WindowController.swift new file mode 100644 index 0000000..bbb8a7f --- /dev/null +++ b/MastoSearch/WindowController.swift @@ -0,0 +1,57 @@ +// +// WindowController.swift +// MastoSearch +// +// Created by Shadowfacts on 12/14/21. +// + +import Cocoa +import Combine + +class WindowController: NSWindowController { + + @IBOutlet weak var searchField: NSSearchField! + + private var querySubject = PassthroughSubject() + private var cancellables = Set() + + private var viewController: ViewController { + contentViewController as! ViewController + } + + override func windowDidLoad() { + super.windowDidLoad() + + querySubject + .debounce(for: .seconds(0.5), scheduler: RunLoop.main) + .sink { [unowned self] (val) in + self.viewController.search(val) + } + .store(in: &cancellables) + + (NSApp.delegate as! AppDelegate).onSync + .sink { [unowned self] in self.updateSubtitle() } + .store(in: &cancellables) + + DatabaseController.shared.onInitialize + .sink { [unowned self] in self.updateSubtitle() } + .store(in: &cancellables) + } + + private func updateSubtitle() { + guard let window = window, + DatabaseController.shared.isInitialized else { + return + } + window.subtitle = "\(DatabaseController.shared.countStatuses().formatted(.number.grouping(.automatic))) statuses" + } + + @IBAction func searchFieldTextChanged(_ sender: Any) { + querySubject.send(searchField.stringValue) + } + + @IBAction func searchMenuItemActivated(_ sender: Any) { + searchField.becomeFirstResponder() + } + +} diff --git a/MastoSearchTests/ImportControllerTests.swift b/MastoSearchTests/ImportControllerTests.swift new file mode 100644 index 0000000..a2b0d28 --- /dev/null +++ b/MastoSearchTests/ImportControllerTests.swift @@ -0,0 +1,23 @@ +// +// ImportControllerTests.swift +// MastoSearchTests +// +// Created by Shadowfacts on 2/20/22. +// + +import XCTest +@testable import MastoSearch + +class ImportControllerTests: XCTestCase { + + func testFlakeID() { + XCTAssertEqual(ImportController.shared.uuidToFlakeIdStr("00000000-0000-0000-0000-00000005bb9e"), "375710") + XCTAssertEqual(ImportController.shared.uuidToFlakeIdStr("00000177-5ba7-ee59-630b-a5bae0760000"), "A3okOJuhGRi5vgGEpk") + } + + func testEncodeBase62() { + XCTAssertEqual(ImportController.shared.encodeBase62(4_815_162_342), "5Frvgk") + XCTAssertEqual(ImportController.shared.encodeBase62(9_223_372_036_854_775_807), "AzL8n0Y58m7") + } + +} diff --git a/MastoSearchTests/MastoSearchTests.swift b/MastoSearchTests/MastoSearchTests.swift new file mode 100644 index 0000000..2e0fc08 --- /dev/null +++ b/MastoSearchTests/MastoSearchTests.swift @@ -0,0 +1,35 @@ +// +// MastoSearchTests.swift +// MastoSearchTests +// +// Created by Shadowfacts on 2/20/22. +// + +import XCTest + +class MastoSearchTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + measure { + // Put the code you want to measure the time of here. + } + } + +}