├── .gitignore ├── AppStore └── appstore-download.svg ├── BookmarkCompanion.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── WorkspaceSettings.xcsettings ├── BookmarkCompanion ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── AppStore_WithoutAlpha_Icon-App-1024x1024.jpg │ │ ├── Contents.json │ │ ├── Icon-App-20x20@1x.png │ │ ├── Icon-App-20x20@2x 1.png │ │ ├── Icon-App-20x20@2x.png │ │ ├── Icon-App-20x20@3x.png │ │ ├── Icon-App-29x29@1x.png │ │ ├── Icon-App-29x29@2x 1.png │ │ ├── Icon-App-29x29@2x.png │ │ ├── Icon-App-29x29@3x.png │ │ ├── Icon-App-40x40@1x.png │ │ ├── Icon-App-40x40@2x 1.png │ │ ├── Icon-App-40x40@2x.png │ │ ├── Icon-App-40x40@3x.png │ │ ├── Icon-App-60x60@2x.png │ │ ├── Icon-App-60x60@3x.png │ │ ├── Icon-App-76x76@1x.png │ │ ├── Icon-App-76x76@2x.png │ │ └── Icon-App-83.5x83.5@2x.png │ └── Contents.json ├── BookmarkCompanion.entitlements ├── BookmarkCompanion.xcdatamodeld │ ├── .xccurrentversion │ └── BookmarkCompanion.xcdatamodel │ │ └── contents ├── BookmarkCompanionApp.swift ├── Info.plist ├── Persistence.swift └── Preview Content │ └── Preview Assets.xcassets │ └── Contents.json ├── CompanionApplication ├── Application.h ├── MainView.swift ├── configuration │ ├── IntegrationDashboardView.swift │ ├── config │ │ └── ConfigurationView.swift │ └── onboarding │ │ └── InitialConfiguration.swift ├── integrations │ └── linkding │ │ ├── LinkdingModel.xcdatamodeld │ │ ├── .xccurrentversion │ │ ├── LinkdingModel 2.xcdatamodel │ │ │ └── contents │ │ └── LinkdingModel.xcdatamodel │ │ │ └── contents │ │ ├── LinkdingPersistenceController.swift │ │ ├── api │ │ ├── LinkdingApiClient.swift │ │ ├── LinkdingBookmarkDto.swift │ │ ├── LinkdingBookmarkUpdateDto.swift │ │ ├── LinkdingTagDto.swift │ │ └── LinkdingTagUpdateDto.swift │ │ ├── domain │ │ ├── bookmark │ │ │ ├── LinkdingBookmarkEntity.swift │ │ │ ├── LinkdingBookmarkRepository.swift │ │ │ └── LinkdingBookmarkStore.swift │ │ └── tag │ │ │ ├── LinkdingTagEntity.swift │ │ │ ├── LinkdingTagRepository.swift │ │ │ └── LinkdingTagStore.swift │ │ ├── migrations │ │ └── AccessTokenMigrationToAppGroup.swift │ │ ├── model │ │ ├── BookmarkModel.swift │ │ └── TagModel.swift │ │ ├── persistence │ │ ├── PersistenceHistoryCleaner.swift │ │ ├── PersistenceHistoryFetcher.swift │ │ ├── PersistenceHistoryMerger.swift │ │ ├── PersistenceHistoryObserver.swift │ │ └── migrations │ │ │ ├── LinkdingModel1To2.swift │ │ │ └── LinkdingModel1To2.xcmappingmodel │ │ │ └── xcmapping.xml │ │ ├── settings │ │ ├── LinkdingSettingKeys.swift │ │ └── LinkdingSettingsValidator.swift │ │ ├── sort │ │ ├── BookmarkSorter.swift │ │ ├── SortField.swift │ │ └── SortOrder.swift │ │ ├── sync │ │ └── LinkdingSyncClient.swift │ │ └── ui │ │ ├── configuration │ │ └── LinkdingSettingsView.swift │ │ └── dashboard │ │ ├── LinkdingDashboardView.swift │ │ ├── bookmarks │ │ ├── LinkdingBookmarkTabView.swift │ │ ├── create │ │ │ ├── LinkdingCreateBookmarkView.swift │ │ │ └── SelectTagsView.swift │ │ ├── edit │ │ │ └── BookmarkEditor.swift │ │ └── filter │ │ │ └── LinkdingBookmarkTabSettingsView.swift │ │ └── tags │ │ ├── CreateTagView.swift │ │ ├── FilterTagView.swift │ │ ├── LinkdingTagBookmarksView.swift │ │ └── LinkdingTagsTabView.swift └── shared │ ├── base │ └── BaseIntegrationDashboard.swift │ ├── components │ ├── bookmark │ │ ├── BookmarkListView.swift │ │ ├── BookmarkTagsView.swift │ │ ├── BookmarkView.swift │ │ └── UrlLinkView.swift │ ├── configuration │ │ └── ConfigurationButton.swift │ ├── dashboard │ │ ├── Dashboard.swift │ │ ├── DashboardTagListItem.swift │ │ ├── DashboardTile.swift │ │ ├── bookmark │ │ │ ├── BookmarkListItemView.swift │ │ │ └── BookmarkListViewV2.swift │ │ └── configuration │ │ │ └── ConfigurationSheet.swift │ ├── error │ │ ├── ErrorView.swift │ │ └── SyncErrorView.swift │ ├── list │ │ ├── CommonSelectListView.swift │ │ └── SelectableListItemView.swift │ ├── tag │ │ ├── TagListView.swift │ │ └── TagView.swift │ └── webview │ │ └── WebView.swift │ ├── config │ ├── GlobalSettingKeys.swift │ └── Integrations.swift │ ├── extensions │ └── View.swift │ ├── model │ ├── Bookmark.swift │ ├── BookmarkStore.swift │ ├── Tag.swift │ └── TagStore.swift │ ├── settings │ ├── AppStorageSupport.swift │ ├── SecureSettingsError.swift │ ├── SecureSettingsSupport.swift │ └── SharedSettingKeys.swift │ └── sync │ ├── BackendSupportClient.swift │ ├── BackendSyncWorker.swift │ └── SyncService.swift ├── CompanionApplicationTests ├── ApplicationTests.swift └── integrations │ └── linkding │ └── persistence │ └── migrations │ └── CoreDataMigration1To2Test.swift ├── Docs ├── Images │ ├── screenshot_ipad.png │ └── screenshot_iphone.png ├── Release.md └── Structure.md ├── LICENSE.md ├── README.md └── ShareExtension ├── ActivationRule.md ├── Info.plist ├── ShareBookmarkContainerView.swift ├── ShareBookmarkCreate.swift ├── ShareBookmarkTagSelect.swift ├── ShareExtension.entitlements └── ShareViewController.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | # Jetbrains AppCode 93 | .idea/ 94 | 95 | # Private xcode config 96 | Config.xcconfig 97 | -------------------------------------------------------------------------------- /AppStore/appstore-download.svg: -------------------------------------------------------------------------------- 1 | 2 | Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /BookmarkCompanion.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /BookmarkCompanion.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /BookmarkCompanion.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/AppStore_WithoutAlpha_Icon-App-1024x1024.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/AppStore_WithoutAlpha_Icon-App-1024x1024.jpg -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-App-20x20@2x.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "Icon-App-20x20@3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "Icon-App-29x29@2x.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "Icon-App-29x29@3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "Icon-App-40x40@2x.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "Icon-App-40x40@3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "Icon-App-60x60@2x.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "Icon-App-60x60@3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "Icon-App-20x20@1x.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "Icon-App-20x20@2x 1.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "Icon-App-29x29@1x.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "Icon-App-29x29@2x 1.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "Icon-App-40x40@1x.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "Icon-App-40x40@2x 1.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "Icon-App-76x76@1x.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "Icon-App-76x76@2x.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "Icon-App-83.5x83.5@2x.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "AppStore_WithoutAlpha_Icon-App-1024x1024.jpg", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x 1.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x 1.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x 1.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/BookmarkCompanion/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /BookmarkCompanion/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /BookmarkCompanion/BookmarkCompanion.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.bookmarkcompanion 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /BookmarkCompanion/BookmarkCompanion.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | BookmarkCompanion.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /BookmarkCompanion/BookmarkCompanion.xcdatamodeld/BookmarkCompanion.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /BookmarkCompanion/BookmarkCompanionApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarkCompanionApp.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | import CompanionApplication 8 | 9 | @main 10 | struct BookmarkCompanionApp: App { 11 | var body: some Scene { 12 | WindowGroup { 13 | MainView() 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /BookmarkCompanion/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ITSAppUsesNonExemptEncryption 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /BookmarkCompanion/Persistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistenceController.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import CoreData 7 | 8 | struct PersistenceController { 9 | static let shared = PersistenceController() 10 | 11 | static var preview: PersistenceController = { 12 | let result = PersistenceController(inMemory: true) 13 | let viewContext = result.container.viewContext 14 | for _ in 0..<10 { 15 | let newItem = Item(context: viewContext) 16 | newItem.timestamp = Date() 17 | } 18 | do { 19 | try viewContext.save() 20 | } catch { 21 | // Replace this implementation with code to handle the error appropriately. 22 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 23 | let nsError = error as NSError 24 | fatalError("Unresolved error \(nsError), \(nsError.userInfo)") 25 | } 26 | return result 27 | }() 28 | 29 | let container: NSPersistentContainer 30 | 31 | init(inMemory: Bool = false) { 32 | container = NSPersistentContainer(name: "BookmarkCompanion") 33 | if inMemory { 34 | container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") 35 | } 36 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 37 | if let error = error as NSError? { 38 | fatalError("Unresolved error \(error), \(error.userInfo)") 39 | } 40 | }) 41 | container.viewContext.automaticallyMergesChangesFromParent = true 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /BookmarkCompanion/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CompanionApplication/Application.h: -------------------------------------------------------------------------------- 1 | // 2 | // Application.h 3 | // Application 4 | // 5 | // Created by Christian Wilhelm on 05.05.23. 6 | // 7 | 8 | #import 9 | 10 | //! Project version number for Application. 11 | FOUNDATION_EXPORT double ApplicationVersionNumber; 12 | 13 | //! Project version string for Application. 14 | FOUNDATION_EXPORT const unsigned char ApplicationVersionString[]; 15 | 16 | // In this header, you should import all the public headers of your framework using statements like #import 17 | 18 | 19 | -------------------------------------------------------------------------------- /CompanionApplication/MainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainView.swift 3 | // CompanionApplication 4 | // 5 | // Created by Christian Wilhelm on 06.05.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct MainView: View { 11 | @AppStorage(LinkdingSettingKeys.configComplete.rawValue, store: AppStorageSupport.shared.sharedStore) var configComplete: Bool = false 12 | 13 | public init() { 14 | } 15 | 16 | public var body: some View { 17 | if (self.configComplete) { 18 | IntegrationDashboardView() 19 | } else { 20 | InitialConfiguration() 21 | } 22 | } 23 | } 24 | 25 | struct MainView_Previews: PreviewProvider { 26 | static var previews: some View { 27 | MainView() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CompanionApplication/configuration/IntegrationDashboardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntegrationDashboardView.swift 3 | // BookmarkCompanion 4 | // 5 | // Created by Christian Wilhelm on 06.01.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct IntegrationDashboardView: View { 11 | @State var openConfig: Bool = false 12 | 13 | var body: some View { 14 | LinkdingDashboardView(openConfig: self.$openConfig) 15 | .sheet(isPresented: self.$openConfig, content: { 16 | NavigationView { 17 | ConfigurationView( 18 | dismissToolbarItem: { 19 | Text("Close") 20 | }, dismissHandler: { 21 | return true 22 | } 23 | ) 24 | .navigationTitle("Configuration") 25 | } 26 | }) 27 | } 28 | } 29 | 30 | struct IntegrationDashboardView_Previews: PreviewProvider { 31 | static var previews: some View { 32 | IntegrationDashboardView() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /CompanionApplication/configuration/config/ConfigurationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigurationView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | import SwiftUI 6 | 7 | enum BookmarkIntegrations { 8 | case linkding 9 | } 10 | 11 | struct ConfigurationView: View { 12 | @AppStorage(SharedSettingKeys.useInAppBrowser.rawValue, store: AppStorageSupport.shared.sharedStore) var useInAppBrowser: Bool = false 13 | @AppStorage(SharedSettingKeys.useExperimentalDashboard.rawValue, store: AppStorageSupport.shared.sharedStore) var useExperimentalDashboard: Bool = false 14 | @AppStorage(SharedSettingKeys.showDescription.rawValue, store: AppStorageSupport.shared.sharedStore) var showDescription: Bool = false 15 | 16 | @Environment(\.presentationMode) private var presentationMode 17 | 18 | @State var selectedIntegration: BookmarkIntegrations = .linkding 19 | @State var hasSettingsError: Bool = false 20 | @State var integrationSelection: String = "linkding" 21 | 22 | private let validator = LinkdingSettingsValidator() 23 | 24 | @ViewBuilder var dismissToolbarItem: () -> DismissToolbarItem 25 | var dismissHandler: () -> Bool 26 | 27 | var body: some View { 28 | List { 29 | Section() { 30 | Picker("Select Bookmark Service", selection: self.$selectedIntegration) { 31 | Text("Linkding").tag(BookmarkIntegrations.linkding) 32 | } 33 | } 34 | switch self.selectedIntegration { 35 | case .linkding: 36 | LinkdingSettingsView() 37 | } 38 | if !ProcessInfo.processInfo.isiOSAppOnMac { 39 | Section("Global") { 40 | Toggle("Use In-App Browser", isOn: self.$useInAppBrowser) 41 | Toggle("Show bookmark description", isOn: self.$showDescription) 42 | Toggle(isOn: self.$useExperimentalDashboard) { 43 | Group { 44 | Text("Use new dashboard style UI") 45 | Text("EXPERIMENTAL") 46 | .bold() 47 | .foregroundColor(Color.red) 48 | } 49 | } 50 | } 51 | } 52 | } 53 | .listStyle(.insetGrouped) 54 | .toolbar { 55 | ToolbarItem(placement: .navigationBarTrailing) { 56 | Button(action: { 57 | let success = self.dismissHandler() 58 | if success { 59 | self.presentationMode.wrappedValue.dismiss() 60 | } 61 | }) { 62 | self.dismissToolbarItem() 63 | } 64 | } 65 | } 66 | } 67 | } 68 | 69 | struct ConfigurationView_Previews: PreviewProvider { 70 | static var previews: some View { 71 | NavigationView { 72 | ConfigurationView(dismissToolbarItem: { 73 | Text("Close") 74 | }, dismissHandler: { 75 | return true 76 | }) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /CompanionApplication/configuration/onboarding/InitialConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InitialConfiguration.swift 3 | // Created by Christian Wilhelm 4 | // 5 | import SwiftUI 6 | 7 | struct InitialConfiguration: View { 8 | @State var showSettingsError: Bool = false 9 | @AppStorage(LinkdingSettingKeys.configComplete.rawValue, store: AppStorageSupport.shared.sharedStore) var configComplete: Bool = false 10 | 11 | var body: some View { 12 | NavigationView { 13 | ConfigurationView( 14 | dismissToolbarItem: { 15 | Text("Save") 16 | }, dismissHandler: { 17 | let validator = LinkdingSettingsValidator() 18 | if (validator.validateSettings()) { 19 | self.configComplete = true 20 | return true 21 | } else { 22 | self.showSettingsError = true 23 | return false 24 | } 25 | } 26 | ) 27 | .navigationTitle("Setup") 28 | .navigationBarBackButtonHidden(true) 29 | .navigationBarTitleDisplayMode(.inline) 30 | .alert(isPresented: self.$showSettingsError) { 31 | Alert(title: Text("Invalid Settings. Please always set URL and Token.")) 32 | } 33 | } 34 | .navigationViewStyle(.stack) 35 | } 36 | } 37 | 38 | struct InitialConfiguration_Previews: PreviewProvider { 39 | static var previews: some View { 40 | InitialConfiguration() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/LinkdingModel.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | LinkdingModel 2.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/LinkdingModel.xcdatamodeld/LinkdingModel 2.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/LinkdingModel.xcdatamodeld/LinkdingModel.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/LinkdingPersistenceController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingPersistenceController.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | import CoreData 8 | 9 | class BookmarkCompanionPersistentContainer: NSPersistentContainer { 10 | override class func defaultDirectoryURL() -> URL { 11 | return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bookmarkcompanion")! 12 | } 13 | } 14 | 15 | public struct LinkdingPersistenceController { 16 | public static let shared = LinkdingPersistenceController() 17 | 18 | private let container: NSPersistentContainer 19 | 20 | private let observer: PersistenceHistoryObserver 21 | 22 | public var viewContext: NSManagedObjectContext { 23 | get { 24 | return self.container.viewContext 25 | } 26 | } 27 | 28 | public func refreshExternalChanges() { 29 | self.viewContext.stalenessInterval = 0 30 | self.viewContext.refreshAllObjects() 31 | self.viewContext.stalenessInterval = -1 32 | } 33 | 34 | public func setViewContextData(name: String, author: String) { 35 | self.viewContext.name = name 36 | self.viewContext.transactionAuthor = author 37 | } 38 | 39 | public init(inMemory: Bool = false) { 40 | container = BookmarkCompanionPersistentContainer(name: "LinkdingModel") 41 | 42 | // Activate History Tracking 43 | guard let description = container.persistentStoreDescriptions.first else { 44 | fatalError() 45 | } 46 | description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) 47 | description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) 48 | 49 | container.persistentStoreDescriptions = [description] 50 | 51 | try? container.viewContext.setQueryGenerationFrom(.current) 52 | if inMemory { 53 | container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") 54 | } 55 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 56 | if let error = error as NSError? { 57 | fatalError("Unresolved error \(error), \(error.userInfo)") 58 | } 59 | }) 60 | 61 | self.observer = PersistenceHistoryObserver(container: container) 62 | self.observer.start() 63 | 64 | container.viewContext.automaticallyMergesChangesFromParent = true 65 | 66 | container.viewContext.shouldDeleteInaccessibleFaults = true 67 | container.viewContext.propagatesDeletesAtEndOfEvent = true 68 | 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/api/LinkdingApiClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingApiClient.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public class LinkdingApiError: Error { 9 | public let message: String 10 | 11 | public init(message: String? = nil) { 12 | self.message = message ?? "" 13 | } 14 | } 15 | 16 | public class LinkdingApiClient: NSObject { 17 | private static let ENDPOINT_TAGS = "/api/tags/" 18 | private static let ENDPOINT_BOOKMARKS = "/api/bookmarks/" 19 | 20 | private let apiToken: String 21 | private let baseUrl: String 22 | private let jsonDecoder: JSONDecoder 23 | private let jsonEncoder: JSONEncoder 24 | private let urlSession: URLSession 25 | 26 | init(baseUrl: String, apiToken: String) { 27 | 28 | self.baseUrl = baseUrl.last == "/" ? 29 | String(baseUrl.dropLast()) : 30 | baseUrl 31 | self.apiToken = apiToken 32 | 33 | let dateFormatterWithFractional: DateFormatter = { 34 | let formatter = DateFormatter() 35 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" 36 | return formatter 37 | }() 38 | let dateFormatterWithoutFractional: DateFormatter = { 39 | let formatter = DateFormatter() 40 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 41 | return formatter 42 | }() 43 | 44 | self.jsonDecoder = JSONDecoder() 45 | self.jsonDecoder.dateDecodingStrategy = .custom({ 46 | let container = try $0.singleValueContainer() 47 | let dateStr = try container.decode(String.self) 48 | 49 | if let withFractional = dateFormatterWithFractional.date(from: dateStr) { 50 | return withFractional 51 | } 52 | 53 | if let withoutFractional = dateFormatterWithoutFractional.date(from: dateStr) { 54 | return withoutFractional 55 | } 56 | 57 | throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unable to parse date string '\(dateStr)'") 58 | }) 59 | self.jsonEncoder = JSONEncoder() 60 | self.jsonEncoder.dateEncodingStrategy = .formatted(dateFormatterWithFractional) 61 | self.jsonEncoder.outputFormatting = .withoutEscapingSlashes 62 | 63 | self.urlSession = URLSession.shared 64 | } 65 | 66 | private func buildRequest(url: URL, httpMethod: String? = nil, body: Data? = nil) throws -> URLRequest { 67 | var request = URLRequest(url: url) 68 | 69 | request.cachePolicy = .reloadIgnoringCacheData 70 | request.setValue("Token \(self.apiToken)", forHTTPHeaderField: "Authorization") 71 | 72 | if (httpMethod != nil) { 73 | request.httpMethod = httpMethod! 74 | } 75 | 76 | if (body != nil) { 77 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 78 | request.httpBody = body! 79 | } 80 | 81 | return request 82 | } 83 | 84 | private func buildUrl(path: [String], query: [String:String] = [:]) throws -> URL { 85 | guard var url = URLComponents(string: self.baseUrl) else { 86 | throw LinkdingApiError(message: "Invalid URL: \(self.baseUrl)") 87 | } 88 | for part in path { 89 | url.path.append(part) 90 | } 91 | if query.count > 0 { 92 | url.queryItems = query.map { 93 | URLQueryItem(name: $0, value: $1) 94 | } 95 | } 96 | guard let url = url.url else { 97 | throw LinkdingApiError(message: "Invalid URL: \(url)") 98 | } 99 | return url.absoluteURL 100 | } 101 | 102 | func loadBookmarks() async throws -> [LinkdingBookmarkDto] { 103 | var nextTargetUrl: URL? = try self.buildUrl(path: [LinkdingApiClient.ENDPOINT_BOOKMARKS]) 104 | var allBookmarks: [LinkdingBookmarkDto] = [] 105 | 106 | while (nextTargetUrl != nil) { 107 | let (next, bookmarks) = try await self.collectBookmarks(url: nextTargetUrl!) 108 | allBookmarks.append(contentsOf: bookmarks) 109 | nextTargetUrl = next != nil ? URL(string: next!) : nil 110 | } 111 | 112 | return allBookmarks 113 | } 114 | 115 | func loadTags() async throws -> [LinkdingTagDto] { 116 | var nextTargetUrl: URL? = try self.buildUrl(path: [LinkdingApiClient.ENDPOINT_TAGS]) 117 | var allTags: [LinkdingTagDto] = [] 118 | 119 | while (nextTargetUrl != nil) { 120 | let (next, tags) = try await self.collectTags(url: nextTargetUrl!) 121 | allTags.append(contentsOf: tags) 122 | nextTargetUrl = next != nil ? URL(string: next!) : nil 123 | } 124 | 125 | return allTags 126 | } 127 | 128 | private func performRequest(request: URLRequest) async throws -> (Data, HTTPURLResponse) { 129 | let content: Data 130 | let response: URLResponse 131 | 132 | do { 133 | (content, response) = try await self.urlSession.data(for: request) 134 | } catch (let error) { 135 | if let urlError = error as? URLError { 136 | throw LinkdingApiError(message: urlError.localizedDescription) 137 | } else { 138 | throw LinkdingApiError(message: "Unknown request error.") 139 | } 140 | } 141 | 142 | return (content, response as! HTTPURLResponse) 143 | } 144 | 145 | private func debugStringForCodingPath(_ path: [CodingKey]) -> String { 146 | return path.map { $0.stringValue } 147 | .joined(separator: " / ") 148 | } 149 | 150 | private func decodeJson(content: Data) throws -> T { 151 | do { 152 | return try self.jsonDecoder.decode(T.self, from: content) 153 | } catch let DecodingError.keyNotFound(key, context) { 154 | let path = self.debugStringForCodingPath(context.codingPath) 155 | throw LinkdingApiError(message: "Error when decoding JSON. Key '\(key.stringValue)' not found on path '\(path)'") 156 | } catch let DecodingError.valueNotFound(_, context) { 157 | let path = self.debugStringForCodingPath(context.codingPath) 158 | throw LinkdingApiError(message: "Error when decoding JSON. Value not found for path '\(path)'.") 159 | } catch let DecodingError.dataCorrupted(context) { 160 | let path = self.debugStringForCodingPath(context.codingPath) 161 | throw LinkdingApiError(message: "Error when decoding JSON. Data corrupted. Message: '\(context.debugDescription)' for path '\(path)'") 162 | } catch let DecodingError.typeMismatch(type, context) { 163 | let path = self.debugStringForCodingPath(context.codingPath) 164 | throw LinkdingApiError(message: "Error when decoding JSON. Type mismatch. Expect type '\(type)' for path '\(path)'") 165 | } catch { 166 | throw LinkdingApiError(message: "Unknown error while decoding JSON.") 167 | } 168 | } 169 | 170 | private func collectTags(url: URL) async throws -> (String?, [LinkdingTagDto]) { 171 | let (content, response) = try await self.performRequest(request: self.buildRequest(url: url)) 172 | 173 | if (response.statusCode != 200) { 174 | throw LinkdingApiError(message: "Server responded with code \(response.statusCode).") 175 | } 176 | 177 | let tags: LinkdingTagListDto = try self.decodeJson(content: content) 178 | return (tags.next, tags.results) 179 | } 180 | 181 | private func collectBookmarks(url: URL) async throws -> (String?, [LinkdingBookmarkDto]) { 182 | let (content, response) = try await self.performRequest(request: self.buildRequest(url: url)) 183 | 184 | if (response.statusCode != 200) { 185 | throw LinkdingApiError(message: "Server responded with code \(response.statusCode).") 186 | } 187 | 188 | let bookmarks: LinkdingBookmarkDtoList = try self.decodeJson(content: content) 189 | return (bookmarks.next, bookmarks.results) 190 | } 191 | 192 | func createBookmark(url: String, title: String, description: String, isArchived: Bool, unread: Bool, shared: Bool, tagNames: [String]) async throws -> LinkdingBookmarkDto { 193 | guard let apiUrl: URL = try? self.buildUrl(path: [LinkdingApiClient.ENDPOINT_BOOKMARKS]) else { 194 | throw LinkdingApiError() 195 | } 196 | guard let postBody = try? self.jsonEncoder.encode(LinkdingBookmarkUpdateDto(url: url, title: title, description: description, isArchived: isArchived, unread: unread, shared: shared, tagNames: tagNames)) else { 197 | throw LinkdingApiError() 198 | } 199 | let (content, response) = try await self.performRequest(request: self.buildRequest(url: apiUrl, httpMethod: "POST", body: postBody)) 200 | 201 | if (response.statusCode != 201) { 202 | throw LinkdingApiError(message: "Server responded with code \(response.statusCode).") 203 | } 204 | 205 | return try self.decodeJson(content: content) 206 | } 207 | 208 | func updateBookmark(serverId: Int, url: String, title: String, description: String, isArchived: Bool, unread: Bool, shared: Bool, tagNames: [String]) async throws -> LinkdingBookmarkDto { 209 | guard let apiUrl: URL = try? self.buildUrl(path: [LinkdingApiClient.ENDPOINT_BOOKMARKS, String("\(serverId)/")]) else { 210 | throw LinkdingApiError() 211 | } 212 | 213 | guard let postBody = try? self.jsonEncoder.encode(LinkdingBookmarkUpdateDto(url: url, title: title, description: description, isArchived: isArchived, unread: unread, shared: shared, tagNames: tagNames)) else { 214 | throw LinkdingApiError() 215 | } 216 | 217 | let (content, response) = try await self.performRequest(request: self.buildRequest(url: apiUrl, httpMethod: "PUT", body: postBody)) 218 | 219 | if (response.statusCode != 200) { 220 | throw LinkdingApiError(message: "Server responded with code \(response.statusCode).") 221 | } 222 | 223 | return try self.decodeJson(content: content) 224 | } 225 | 226 | func deleteBookmark(serverId: Int) async throws { 227 | guard let apiUrl: URL = try? self.buildUrl(path: [LinkdingApiClient.ENDPOINT_BOOKMARKS, String("\(serverId)/")]) else { 228 | throw LinkdingApiError() 229 | } 230 | 231 | let (_, response) = try await self.performRequest(request: self.buildRequest(url: apiUrl, httpMethod: "DELETE")) 232 | 233 | if (response.statusCode == 404) { 234 | // Bookmark was already deleted on the server 235 | return 236 | } 237 | 238 | if (response.statusCode != 204) { 239 | throw LinkdingApiError(message: "Server responded with code \(response.statusCode).") 240 | } 241 | } 242 | 243 | func createTag(name: String) async throws -> LinkdingTagDto { 244 | guard let apiUrl: URL = try? self.buildUrl(path: [LinkdingApiClient.ENDPOINT_TAGS]) else { 245 | throw LinkdingApiError() 246 | } 247 | 248 | guard let postBody = try? self.jsonEncoder.encode(LinkdingTagUpdateDto(name: name)) else { 249 | throw LinkdingApiError() 250 | } 251 | 252 | let (content, response) = try await self.performRequest(request: self.buildRequest(url: apiUrl, httpMethod: "POST", body: postBody)) 253 | 254 | if (response.statusCode != 201) { 255 | throw LinkdingApiError(message: "Server responded with code \(response.statusCode).") 256 | } 257 | 258 | return try self.decodeJson(content: content) 259 | } 260 | 261 | func apiAvailable() async throws -> Bool { 262 | guard let url: URL = try? self.buildUrl( 263 | path: [LinkdingApiClient.ENDPOINT_BOOKMARKS], 264 | query: ["limit": "1"] 265 | ) else { 266 | return false 267 | } 268 | let (_, response) = try await self.performRequest(request: self.buildRequest(url: url)) 269 | 270 | if (response.statusCode != 200) { 271 | return false 272 | } 273 | 274 | return true 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/api/LinkdingBookmarkDto.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingBookmarkDto.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | struct LinkdingBookmarkDtoList: Codable { 9 | var count: Int 10 | var next: String? 11 | var previous: String? 12 | var results: [LinkdingBookmarkDto] 13 | } 14 | 15 | 16 | struct LinkdingBookmarkDto: Codable, Identifiable { 17 | var id: Int 18 | var url: String 19 | var title: String 20 | var description: String 21 | var websiteTitle: String? 22 | var websiteDescription: String? 23 | var isArchived: Bool 24 | var unread: Bool 25 | var shared: Bool 26 | var tags: [String] 27 | var dateAdded: Date 28 | var dateModified: Date 29 | 30 | enum CodingKeys: String, CodingKey { 31 | case id 32 | case url 33 | case title 34 | case description 35 | case websiteTitle = "website_title" 36 | case websiteDescription = "website_description" 37 | case isArchived = "is_archived" 38 | case unread 39 | case shared 40 | case tags = "tag_names" 41 | case dateAdded = "date_added" 42 | case dateModified = "date_modified" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/api/LinkdingBookmarkUpdateDto.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingBookmarkUpdateDto.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | struct LinkdingBookmarkUpdateDto: Codable { 9 | var url: String 10 | var title: String 11 | var description: String 12 | var isArchived: Bool 13 | var unread: Bool 14 | var shared: Bool 15 | var tagNames: [String] 16 | 17 | enum CodingKeys: String, CodingKey { 18 | case url 19 | case title 20 | case description 21 | case isArchived = "is_archived" 22 | case unread 23 | case shared 24 | case tagNames = "tag_names" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/api/LinkdingTagDto.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingTagDto.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | struct LinkdingTagDto: Codable, Identifiable { 9 | var id: Int 10 | var name: String 11 | var dateAdded: Date 12 | 13 | enum CodingKeys: String, CodingKey { 14 | case id 15 | case name 16 | case dateAdded = "date_added" 17 | } 18 | } 19 | 20 | struct LinkdingTagListDto: Codable { 21 | var count: Int 22 | var next: String? 23 | var previous: String? 24 | var results: [LinkdingTagDto] 25 | } 26 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/api/LinkdingTagUpdateDto.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingTagUpdateDto.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | struct LinkdingTagUpdateDto: Codable { 9 | var name: String 10 | } 11 | 12 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/domain/bookmark/LinkdingBookmarkEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingBookmarkEntity.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | import CoreData 8 | 9 | @objc(LinkdingBookmarkEntity) 10 | public class LinkdingBookmarkEntity: NSManagedObject, Identifiable { 11 | @NSManaged private(set) public var serverId: Int // is: id in API 12 | @NSManaged private(set) public var internalId: UUID 13 | @NSManaged private(set) public var url: String 14 | @NSManaged private(set) public var title: String 15 | @NSManaged private(set) public var urlDescription: String // is: description in API 16 | @NSManaged private(set) public var websiteTitle: String? 17 | @NSManaged private(set) public var websiteDescription: String? 18 | @NSManaged private(set) public var isArchived: Bool 19 | @NSManaged private(set) public var unread: Bool 20 | @NSManaged private(set) public var shared: Bool 21 | @NSManaged private(set) public var dateAdded: Date? 22 | @NSManaged private(set) public var dateModified: Date? 23 | @NSManaged private(set) public var locallyDeleted: Bool 24 | @NSManaged private(set) public var locallyModified: Bool 25 | @NSManaged private(set) public var relTags: NSSet 26 | 27 | public var tagNames: [String] { 28 | get { 29 | self.relTags.map { tag in 30 | let castedTag = tag as! LinkdingTagEntity 31 | return castedTag.name 32 | } 33 | } 34 | } 35 | 36 | public var tags: [Tag] { 37 | get { 38 | self.relTags.map { tag in 39 | let castedTag = tag as! LinkdingTagEntity 40 | return Tag(id: castedTag.internalId, name: castedTag.name) 41 | } 42 | } 43 | } 44 | 45 | public var tagEntities: [LinkdingTagEntity] { 46 | get { 47 | self.relTags.map { $0 as! LinkdingTagEntity } 48 | } 49 | } 50 | 51 | public func needsUpdate( 52 | serverId: Int, 53 | url: String, 54 | title: String, 55 | urlDescription: String, 56 | websiteTitle: String?, 57 | websiteDescription: String?, 58 | isArchived: Bool, 59 | unread: Bool, 60 | shared: Bool, 61 | dateAdded: Date?, 62 | dateModified: Date?, 63 | tags: [String] 64 | ) -> Bool { 65 | return self.serverId != serverId || 66 | self.url != url || 67 | self.title != title || 68 | self.urlDescription != urlDescription || 69 | self.websiteTitle != websiteTitle || 70 | self.websiteDescription != websiteDescription || 71 | self.isArchived != isArchived || 72 | self.unread != unread || 73 | self.shared != shared || 74 | self.dateAdded != dateAdded || 75 | self.dateModified != dateModified || 76 | Set(self.tagNames) != Set(tags) 77 | } 78 | 79 | public func updateServerData( 80 | serverId: Int, 81 | url: String, 82 | title: String, 83 | urlDescription: String, 84 | websiteTitle: String?, 85 | websiteDescription: String?, 86 | isArchived: Bool, 87 | unread: Bool, 88 | shared: Bool, 89 | dateAdded: Date?, 90 | dateModified: Date?, 91 | tags: [LinkdingTagEntity] 92 | ) { 93 | self.serverId = serverId 94 | self.url = url 95 | self.title = title 96 | self.urlDescription = urlDescription 97 | self.websiteTitle = websiteTitle 98 | self.websiteDescription = websiteDescription 99 | self.isArchived = isArchived 100 | self.unread = unread 101 | self.shared = shared 102 | self.dateAdded = dateAdded 103 | self.dateModified = dateModified 104 | self.relTags = NSSet(array: tags) 105 | self.locallyDeleted = false 106 | self.locallyModified = false 107 | } 108 | 109 | public func updateFlags(isArchived: Bool, unread: Bool, shared: Bool) { 110 | self.isArchived = isArchived 111 | self.unread = unread 112 | self.shared = shared 113 | self.locallyModified = true 114 | } 115 | 116 | public func updateTags(tags: [LinkdingTagEntity]) { 117 | self.relTags = NSSet(array: tags) 118 | self.locallyModified = true 119 | } 120 | 121 | public func updateData(url: String, title: String, urlDescription: String) { 122 | self.url = url 123 | self.title = title 124 | self.urlDescription = urlDescription 125 | self.locallyModified = true 126 | } 127 | 128 | public func markAsDeleted() { 129 | self.locallyDeleted = true 130 | } 131 | 132 | public func markAsOk() { 133 | self.locallyModified = false 134 | } 135 | 136 | public var localOnly: Bool { 137 | get { 138 | return self.serverId == 0 139 | } 140 | } 141 | 142 | public var displayTitle: String { 143 | get { 144 | if (self.title != "") { 145 | return self.title 146 | } 147 | 148 | if (self.websiteTitle != nil && self.websiteTitle != "") { 149 | return self.websiteTitle! 150 | } 151 | 152 | return self.url 153 | } 154 | } 155 | 156 | public var displayDescription: String { 157 | get { 158 | if (self.urlDescription != "") { 159 | return self.urlDescription 160 | } 161 | 162 | if (self.websiteDescription != nil && self.websiteDescription != "") { 163 | return self.websiteDescription! 164 | } 165 | 166 | return "" 167 | } 168 | } 169 | } 170 | 171 | extension LinkdingBookmarkEntity { 172 | public static func createBookmark( 173 | moc: NSManagedObjectContext, 174 | url: String, 175 | title: String, 176 | urlDescription: String 177 | ) -> LinkdingBookmarkEntity { 178 | let entity = LinkdingBookmarkEntity.init(context: moc) 179 | 180 | entity.serverId = 0 181 | entity.internalId = UUID() 182 | entity.url = url 183 | entity.title = title 184 | entity.urlDescription = urlDescription 185 | entity.isArchived = false 186 | entity.unread = true 187 | entity.shared = false 188 | entity.locallyDeleted = false 189 | entity.locallyModified = false 190 | 191 | return entity 192 | } 193 | } 194 | 195 | extension LinkdingBookmarkEntity { 196 | public class func loadBookmarks() -> NSFetchRequest { 197 | let request = NSFetchRequest(entityName: "Bookmark") 198 | 199 | request.sortDescriptors = [ 200 | NSSortDescriptor(keyPath: \LinkdingBookmarkEntity.url, ascending: true), 201 | NSSortDescriptor(keyPath: \LinkdingBookmarkEntity.title, ascending: true) 202 | ] 203 | request.relationshipKeyPathsForPrefetching = ["relTags"] 204 | 205 | return request 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/domain/bookmark/LinkdingBookmarkRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingBookmarkRepository.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public class LinkdingBookmarkRepository { 9 | private let bookmarkStore: LinkdingBookmarkStore 10 | private let tagStore: LinkdingTagStore 11 | 12 | public init(bookmarkStore: LinkdingBookmarkStore, tagStore: LinkdingTagStore) { 13 | self.bookmarkStore = bookmarkStore 14 | self.tagStore = tagStore 15 | } 16 | 17 | private func getOrCreateByServerId(serverId: Int, url: String, title: String, urlDescription: String) -> LinkdingBookmarkEntity { 18 | guard let bookmark = self.bookmarkStore.getByServerId(serverId: serverId) else { 19 | return LinkdingBookmarkEntity.createBookmark(moc: LinkdingPersistenceController.shared.viewContext, url: url, title: title, urlDescription: urlDescription) 20 | } 21 | return bookmark 22 | } 23 | 24 | @MainActor 25 | public func deleteBookmark(bookmark: LinkdingBookmarkEntity) { 26 | LinkdingPersistenceController.shared.viewContext.delete(bookmark) 27 | try? LinkdingPersistenceController.shared.viewContext.save() 28 | } 29 | 30 | @MainActor 31 | public func deleteBookmarkByServerId(serverId: Int) { 32 | guard let bookmark = self.bookmarkStore.getByServerId(serverId: serverId) else { 33 | return 34 | } 35 | 36 | LinkdingPersistenceController.shared.viewContext.delete(bookmark) 37 | try? LinkdingPersistenceController.shared.viewContext.save() 38 | } 39 | 40 | @MainActor 41 | public func updateExistingBookmarkFromServer( 42 | bookmark: LinkdingBookmarkEntity, 43 | serverId: Int, 44 | url: String, 45 | title: String, 46 | urlDescription: String, 47 | websiteTitle: String?, 48 | websiteDescription: String?, 49 | isArchived: Bool, 50 | unread: Bool, 51 | shared: Bool, 52 | dateAdded: Date?, 53 | dateModified: Date?, 54 | tags: [String] 55 | ) { 56 | bookmark.updateServerData( 57 | serverId: serverId, 58 | url: url, 59 | title: title, 60 | urlDescription: urlDescription, 61 | websiteTitle: websiteTitle, 62 | websiteDescription: websiteDescription, 63 | isArchived: isArchived, 64 | unread: unread, 65 | shared: shared, 66 | dateAdded: dateAdded, 67 | dateModified: dateModified, 68 | tags: self.tagStore.getByNameList(names: tags) 69 | ) 70 | try? LinkdingPersistenceController.shared.viewContext.save() 71 | } 72 | 73 | @MainActor 74 | public func createOrUpdateBookmarkFromServer( 75 | serverId: Int, 76 | url: String, 77 | title: String, 78 | urlDescription: String, 79 | websiteTitle: String?, 80 | websiteDescription: String?, 81 | isArchived: Bool, 82 | unread: Bool, 83 | shared: Bool, 84 | dateAdded: Date?, 85 | dateModified: Date?, 86 | tags: [String] 87 | ) { 88 | let bookmark = self.getOrCreateByServerId(serverId: serverId, url: url, title: title, urlDescription: urlDescription) 89 | let needsUpdate = bookmark.needsUpdate(serverId: bookmark.serverId, url: url, title: title, urlDescription: urlDescription, websiteTitle: websiteTitle, websiteDescription: websiteDescription, isArchived: isArchived, unread: unread, shared: shared, dateAdded: dateAdded, dateModified: dateModified, tags: tags) 90 | if (needsUpdate) { 91 | bookmark.updateServerData( 92 | serverId: serverId, 93 | url: url, 94 | title: title, 95 | urlDescription: urlDescription, 96 | websiteTitle: websiteTitle, 97 | websiteDescription: websiteDescription, 98 | isArchived: isArchived, 99 | unread: unread, 100 | shared: shared, 101 | dateAdded: dateAdded, 102 | dateModified: dateModified, 103 | tags: self.tagStore.getByNameList(names: tags) 104 | ) 105 | try? LinkdingPersistenceController.shared.viewContext.save() 106 | } 107 | } 108 | 109 | @MainActor 110 | public func createNewBookmark(url: String, title: String, description: String, isArchived: Bool, unread: Bool, shared: Bool, tags: [String]) -> LinkdingBookmarkEntity { 111 | let bookmark = LinkdingBookmarkEntity.createBookmark(moc: LinkdingPersistenceController.shared.viewContext, url: url, title: title, urlDescription: description) 112 | bookmark.updateFlags(isArchived: isArchived, unread: unread, shared: shared) 113 | bookmark.updateTags(tags: self.tagStore.getByNameList(names: tags)) 114 | 115 | try? LinkdingPersistenceController.shared.viewContext.save() 116 | 117 | return bookmark 118 | } 119 | 120 | @MainActor 121 | public func updateBookmark(bookmark: LinkdingBookmarkEntity, url: String, title: String, description: String, isArchived: Bool, unread: Bool, shared: Bool, tags: [String]) -> LinkdingBookmarkEntity { 122 | bookmark.updateData(url: url, title: title, urlDescription: description) 123 | bookmark.updateFlags(isArchived: isArchived, unread: unread, shared: shared) 124 | bookmark.updateTags(tags: self.tagStore.getByNameList(names: tags)) 125 | 126 | try? LinkdingPersistenceController.shared.viewContext.save() 127 | 128 | return bookmark 129 | } 130 | 131 | @MainActor 132 | public func markAsDeleted(bookmark: LinkdingBookmarkEntity) { 133 | if (bookmark.localOnly) { 134 | LinkdingPersistenceController.shared.viewContext.delete(bookmark) 135 | } else { 136 | bookmark.markAsDeleted() 137 | } 138 | 139 | try? LinkdingPersistenceController.shared.viewContext.save() 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/domain/bookmark/LinkdingBookmarkStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingBookmarkStore.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import CoreData 7 | 8 | public class LinkdingBookmarkStore: NSObject, ObservableObject { 9 | @Published private(set) public var allBookmarks: [LinkdingBookmarkEntity] = [] 10 | 11 | private let bookmarkFetchController: NSFetchedResultsController 12 | 13 | override public init() { 14 | self.bookmarkFetchController = NSFetchedResultsController( 15 | fetchRequest: LinkdingBookmarkEntity.loadBookmarks(), 16 | managedObjectContext: LinkdingPersistenceController.shared.viewContext, 17 | sectionNameKeyPath: nil, 18 | cacheName: nil 19 | ) 20 | 21 | super.init() 22 | 23 | self.bookmarkFetchController.delegate = self 24 | do { 25 | try self.bookmarkFetchController.performFetch() 26 | self.allBookmarks = self.bookmarkFetchController.fetchedObjects ?? [] 27 | } catch (let error) { 28 | debugPrint(error) 29 | } 30 | } 31 | 32 | public var bookmarks: [LinkdingBookmarkEntity] { 33 | get { 34 | return self.allBookmarks 35 | .filter { $0.locallyDeleted == false } 36 | } 37 | } 38 | 39 | public func getByServerId(serverId: Int) -> LinkdingBookmarkEntity? { 40 | return self.bookmarks 41 | .filter { $0.serverId == serverId } 42 | .first 43 | } 44 | 45 | public func getByInternalId(internalId: UUID) -> LinkdingBookmarkEntity? { 46 | return self.bookmarks 47 | .filter { $0.internalId == internalId } 48 | .first 49 | } 50 | 51 | public func getByUrl(url: String) -> LinkdingBookmarkEntity? { 52 | return self.bookmarks 53 | .filter { $0.url == url } 54 | .first 55 | } 56 | 57 | public func filtered(showArchived: Bool, showUnreadOnly: Bool, filterText: String) -> [LinkdingBookmarkEntity] { 58 | return self.bookmarks 59 | .filter { 60 | if (showArchived == true) { 61 | return true 62 | } else { 63 | return $0.isArchived == false 64 | } 65 | } 66 | .filter { 67 | if (showUnreadOnly) { 68 | return $0.unread 69 | } 70 | return true 71 | } 72 | .filter { 73 | if (filterText == "") { 74 | return true 75 | } 76 | let tagFilterText = filterText.first == "#" ? 77 | String(filterText.dropFirst()) : 78 | filterText 79 | let tagFound = $0.tagNames.filter { 80 | $0.lowercased().starts(with: tagFilterText.lowercased()) 81 | }.count > 0 82 | let titleFound = $0.title.lowercased().contains(filterText.lowercased()) 83 | let urlFound = $0.url.lowercased().contains(filterText.lowercased()) 84 | let websiteTitleFound = $0.websiteTitle != nil ? $0.websiteTitle!.lowercased().contains(filterText.lowercased()) : false 85 | return tagFound || titleFound || urlFound || websiteTitleFound 86 | } 87 | } 88 | 89 | public var untagged: [LinkdingBookmarkEntity] { 90 | get { 91 | return self.bookmarks 92 | .filter { $0.tagNames.count == 0 } 93 | } 94 | } 95 | 96 | public var notArchivedOnly: [LinkdingBookmarkEntity] { 97 | get { 98 | return self.bookmarks 99 | .filter { $0.isArchived == false } 100 | } 101 | } 102 | 103 | public var onlyNewBookmarks: [LinkdingBookmarkEntity] { 104 | get { 105 | return self.bookmarks 106 | .filter { $0.serverId == 0 } 107 | } 108 | } 109 | 110 | public var onlyModifiedBookmarks: [LinkdingBookmarkEntity] { 111 | get { 112 | return self.bookmarks 113 | .filter { $0.locallyModified == true } 114 | } 115 | } 116 | 117 | public var onlyLocallyDeletedBookmarks: [LinkdingBookmarkEntity] { 118 | get { 119 | return self.allBookmarks 120 | .filter { $0.locallyDeleted == true } 121 | } 122 | } 123 | } 124 | 125 | extension LinkdingBookmarkStore: NSFetchedResultsControllerDelegate { 126 | public func controllerDidChangeContent(_ controller: NSFetchedResultsController) { 127 | guard let bookmarkEntities = controller.fetchedObjects as? [LinkdingBookmarkEntity] else { 128 | return 129 | } 130 | 131 | self.allBookmarks = bookmarkEntities 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/domain/tag/LinkdingTagEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingTagEntity.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | import CoreData 8 | 9 | @objc(LinkdingTagEntity) 10 | public class LinkdingTagEntity: NSManagedObject, Identifiable { 11 | @NSManaged private(set) public var serverId: Int // is: id in API 12 | @NSManaged private(set) public var internalId: UUID 13 | @NSManaged private(set) public var name: String 14 | @NSManaged private(set) public var dateAdded: Date? 15 | @NSManaged private(set) public var relBookmarks: NSSet 16 | 17 | public var bookmarks: [LinkdingBookmarkEntity] { 18 | get { 19 | self.relBookmarks.map({ bookmark in 20 | bookmark as! LinkdingBookmarkEntity 21 | }) 22 | } 23 | } 24 | 25 | public var id: UUID { 26 | get { 27 | return self.internalId 28 | } 29 | } 30 | 31 | public func updateServerData(serverId: Int, name: String, dateAdded: Date?) { 32 | self.serverId = serverId 33 | self.name = name 34 | self.dateAdded = dateAdded 35 | } 36 | } 37 | 38 | extension LinkdingTagEntity { 39 | public static func createTag( 40 | moc: NSManagedObjectContext, 41 | name: String 42 | ) -> LinkdingTagEntity { 43 | let entity = LinkdingTagEntity.init(context: moc) 44 | 45 | entity.serverId = 0 46 | entity.internalId = UUID() 47 | entity.name = name 48 | 49 | return entity 50 | } 51 | } 52 | 53 | extension LinkdingTagEntity { 54 | public class func loadTags() -> NSFetchRequest { 55 | let request = NSFetchRequest(entityName: "Tag") 56 | 57 | request.sortDescriptors = [ 58 | NSSortDescriptor(keyPath: \LinkdingTagEntity.name, ascending: true) 59 | ] 60 | request.relationshipKeyPathsForPrefetching = ["relBookmarks"] 61 | 62 | return request 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/domain/tag/LinkdingTagRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingTagRepository.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public class LinkdingTagRepository { 9 | private let tagStore: LinkdingTagStore 10 | 11 | public init(tagStore: LinkdingTagStore) { 12 | self.tagStore = tagStore 13 | } 14 | 15 | public func createTag(tag: TagModel) -> LinkdingTagEntity { 16 | let entity = self.getOrCreateEntity(serverId: tag.serverId, name: tag.name) 17 | entity.updateServerData(serverId: tag.serverId ?? 0, name: tag.name, dateAdded: tag.dateAdded) 18 | try? LinkdingPersistenceController.shared.viewContext.save() 19 | return entity 20 | } 21 | 22 | public func updateTag(entity: LinkdingTagEntity, updateData: TagModel) { 23 | LinkdingPersistenceController.shared.viewContext.perform { 24 | entity.updateServerData(serverId: updateData.serverId ?? 0, name: updateData.name, dateAdded: updateData.dateAdded) 25 | try? LinkdingPersistenceController.shared.viewContext.save() 26 | } 27 | } 28 | 29 | public func batchApplyChanges(models: [TagModel]) { 30 | LinkdingPersistenceController.shared.viewContext.performAndWait { 31 | models.forEach { 32 | let entity = self.getOrCreateEntity(serverId: $0.serverId, name: $0.name) 33 | entity.updateServerData(serverId: $0.serverId ?? 0, name: $0.name, dateAdded: $0.dateAdded) 34 | } 35 | try? LinkdingPersistenceController.shared.viewContext.save() 36 | } 37 | } 38 | 39 | public func batchDeleteServerIds(serverIds: [Int]) { 40 | LinkdingPersistenceController.shared.viewContext.performAndWait { 41 | for serverId in serverIds { 42 | guard let tag = self.tagStore.getByServerId(serverId: serverId) else { 43 | return 44 | } 45 | LinkdingPersistenceController.shared.viewContext.delete(tag) 46 | } 47 | try? LinkdingPersistenceController.shared.viewContext.save() 48 | } 49 | } 50 | 51 | private func getOrCreateEntity(serverId: Int?, name: String) -> LinkdingTagEntity { 52 | guard let id = serverId else { 53 | return LinkdingTagEntity.createTag(moc: LinkdingPersistenceController.shared.viewContext, name: name) 54 | } 55 | 56 | guard let entity = self.tagStore.getByServerId(serverId: id) else { 57 | return LinkdingTagEntity.createTag(moc: LinkdingPersistenceController.shared.viewContext, name: name) 58 | } 59 | 60 | return entity 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/domain/tag/LinkdingTagStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingTagStore.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import CoreData 7 | 8 | public class LinkdingTagStore: NSObject, ObservableObject { 9 | @Published private(set) public var tags: [LinkdingTagEntity] = [] 10 | 11 | private let tagFetchController: NSFetchedResultsController 12 | 13 | override public init() { 14 | self.tagFetchController = NSFetchedResultsController( 15 | fetchRequest: LinkdingTagEntity.loadTags(), 16 | managedObjectContext: LinkdingPersistenceController.shared.viewContext, 17 | sectionNameKeyPath: nil, 18 | cacheName: nil 19 | ) 20 | 21 | super.init() 22 | 23 | self.tagFetchController.delegate = self 24 | do { 25 | try self.tagFetchController.performFetch() 26 | self.tags = self.tagFetchController.fetchedObjects ?? [] 27 | } catch (let error) { 28 | debugPrint(error) 29 | } 30 | } 31 | 32 | public func getByServerId(serverId: Int) -> LinkdingTagEntity? { 33 | return self.tags 34 | .filter { $0.serverId == serverId } 35 | .first 36 | } 37 | 38 | public func getByInternalIdList(uuids: [UUID]) -> [LinkdingTagEntity] { 39 | return self.tags 40 | .filter { uuids.contains($0.internalId) } 41 | } 42 | 43 | public func getByNameList(names: [String]) -> [LinkdingTagEntity] { 44 | return self.tags 45 | .filter { names.contains($0.name) } 46 | } 47 | 48 | public var usedTags: [LinkdingTagEntity] { 49 | get { 50 | self.tags 51 | .filter { $0.bookmarks.count > 0 } 52 | } 53 | } 54 | 55 | public func filteredTags(nameFilter: String, onlyUsed: Bool) -> [LinkdingTagEntity] { 56 | let tags = onlyUsed ? self.usedTags : self.tags 57 | 58 | if (nameFilter == "") { 59 | return tags 60 | } 61 | 62 | return tags.filter { $0.name.lowercased().contains(nameFilter.lowercased()) } 63 | } 64 | 65 | public var onlyLocalTags: [LinkdingTagEntity] { 66 | get { 67 | self.tags.filter { $0.serverId == 0 } 68 | } 69 | } 70 | } 71 | 72 | extension LinkdingTagStore: NSFetchedResultsControllerDelegate { 73 | public func controllerDidChangeContent(_ controller: NSFetchedResultsController) { 74 | guard let tagEntities = controller.fetchedObjects as? [LinkdingTagEntity] else { 75 | return 76 | } 77 | 78 | self.tags = tagEntities 79 | } 80 | } 81 | 82 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/migrations/AccessTokenMigrationToAppGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessTokenMigrationToAppGroup.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public class AccessTokenMigrationToAppGroup { 9 | public func migrate() { 10 | if self.needsUpdate() { 11 | self.updateAccessGroup() 12 | } 13 | } 14 | 15 | private func updateAccessGroup() { 16 | guard let token = self.getTokenFromKeychain() else { 17 | return 18 | } 19 | self.deleteToken() 20 | try? SecureSettingsSupport.setSecureSettingString( 21 | key: LinkdingSettingKeys.settingsToken.rawValue, 22 | value: token 23 | ) 24 | } 25 | 26 | private func needsUpdate() -> Bool { 27 | let keychainItemQuery = [ 28 | kSecAttrAccount: LinkdingSettingKeys.settingsToken.rawValue, 29 | kSecClass: kSecClassGenericPassword, 30 | kSecReturnAttributes: true, 31 | kSecReturnData: true, 32 | kSecMatchLimit: 1 33 | ] as CFDictionary 34 | var result: AnyObject? 35 | SecItemCopyMatching(keychainItemQuery, &result) 36 | guard let data = result else { 37 | return false 38 | } 39 | guard let resultData = data[kSecAttrAccessGroup] else { 40 | return false 41 | } 42 | guard let accessGroup = resultData else { 43 | return false 44 | } 45 | return accessGroup as! String != "group.bookmarkcompanion" 46 | } 47 | 48 | private func getTokenFromKeychain() -> String? { 49 | let keychainItemQuery = [ 50 | kSecAttrAccount: LinkdingSettingKeys.settingsToken.rawValue, 51 | kSecClass: kSecClassGenericPassword, 52 | kSecReturnAttributes: true, 53 | kSecReturnData: true, 54 | kSecMatchLimit: 1 55 | ] as CFDictionary 56 | var result: AnyObject? 57 | SecItemCopyMatching(keychainItemQuery, &result) 58 | guard let data = result else { 59 | return nil 60 | } 61 | let resultData = data[kSecValueData] as! Data 62 | guard let strValue = String(data: resultData, encoding: .utf8) else { 63 | return nil 64 | } 65 | return strValue 66 | } 67 | 68 | private func deleteToken() { 69 | let keychainItemQuery = [ 70 | kSecAttrAccount: LinkdingSettingKeys.settingsToken.rawValue, 71 | kSecClass: kSecClassGenericPassword 72 | ] as CFDictionary 73 | SecItemDelete(keychainItemQuery) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/model/BookmarkModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarkModel.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public struct BookmarkModel { 9 | var serverId: Int? 10 | var url: String 11 | var title: String 12 | var description: String 13 | var websiteTitle: String? 14 | var websiteDescription: String? 15 | var archived: Bool 16 | var unread: Bool 17 | var shared: Bool 18 | var dateAdded: Date? 19 | var dateModified: Date? 20 | var tags: [String] 21 | } 22 | 23 | extension LinkdingBookmarkEntity { 24 | public func toModel() -> BookmarkModel { 25 | return BookmarkModel( 26 | serverId: self.serverId == 0 ? nil : self.serverId, 27 | url: self.url, 28 | title: self.title, 29 | description: self.urlDescription, 30 | websiteTitle: self.websiteTitle, 31 | websiteDescription: self.websiteDescription, 32 | archived: self.isArchived, 33 | unread: self.unread, 34 | shared: self.shared, 35 | dateAdded: self.dateAdded, 36 | dateModified: self.dateModified, 37 | tags: self.tagNames 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/model/TagModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagModel.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public struct TagModel { 9 | public init(serverId: Int? = nil, name: String, dateAdded: Date? = nil) { 10 | self.serverId = serverId 11 | self.name = name 12 | self.dateAdded = dateAdded 13 | } 14 | 15 | public var serverId: Int? 16 | public var name: String 17 | public var dateAdded: Date? 18 | } 19 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/persistence/PersistenceHistoryCleaner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistenceHistoryCleaner.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | import CoreData 8 | 9 | struct PersistenceHistoryCleaner { 10 | let timestampKey: String = LinkdingSettingKeys.persistenceHistoryLastTransactionTimestamp.rawValue 11 | let context: NSManagedObjectContext 12 | 13 | func cleanPersistenceHistory() throws { 14 | guard let fromDate = UserDefaults.standard.object(forKey: timestampKey) as? Date else { 15 | return 16 | } 17 | 18 | let deleteHistoryRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: fromDate) 19 | try context.execute(deleteHistoryRequest) 20 | 21 | UserDefaults.standard.set(nil, forKey: timestampKey) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/persistence/PersistenceHistoryFetcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistenceHistoryFetcher.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | import CoreData 8 | 9 | struct PersistenceHistoryFetcher { 10 | enum Error: Swift.Error { 11 | case historyTransactionFailed 12 | } 13 | 14 | let context: NSManagedObjectContext 15 | let fromDate: Date 16 | 17 | func fetchHistoryTransactions() throws -> [NSPersistentHistoryTransaction] { 18 | let fetchRequest = self.buildFetchRequest() 19 | 20 | guard let historyResult = try context.execute(fetchRequest) as? NSPersistentHistoryResult, let history = historyResult.result as? [NSPersistentHistoryTransaction] else { 21 | throw Error.historyTransactionFailed 22 | } 23 | 24 | return history 25 | } 26 | 27 | func buildFetchRequest() -> NSPersistentHistoryChangeRequest { 28 | let historyFetchRequest = NSPersistentHistoryChangeRequest 29 | .fetchHistory(after: fromDate) 30 | 31 | if let fetchRequest = NSPersistentHistoryTransaction.fetchRequest { 32 | var predicates: [NSPredicate] = [] 33 | 34 | if let transactionAuthor = context.transactionAuthor { 35 | predicates.append(NSPredicate(format: "%K != %@", #keyPath(NSPersistentHistoryTransaction.author), transactionAuthor)) 36 | } 37 | if let contextName = context.name { 38 | predicates.append(NSPredicate(format: "%K != %@", #keyPath(NSPersistentHistoryTransaction.contextName), contextName)) 39 | } 40 | 41 | fetchRequest.predicate = NSCompoundPredicate(type: .and, subpredicates: predicates) 42 | historyFetchRequest.fetchRequest = fetchRequest 43 | } 44 | 45 | return historyFetchRequest 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/persistence/PersistenceHistoryMerger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistenceHistoryMerger.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | import CoreData 8 | 9 | struct PersistenceHistoryMerger { 10 | let timestampKey: String = LinkdingSettingKeys.persistenceHistoryLastTransactionTimestamp.rawValue 11 | 12 | let backgroundContext: NSManagedObjectContext 13 | let viewContext: NSManagedObjectContext 14 | 15 | func mergeHistory() throws { 16 | let fromDate = UserDefaults.standard.object(forKey: timestampKey) as? Date ?? .distantPast 17 | 18 | let fetcher = PersistenceHistoryFetcher(context: backgroundContext, fromDate: fromDate) 19 | let history = try fetcher.fetchHistoryTransactions() 20 | 21 | guard !history.isEmpty else { 22 | return 23 | } 24 | 25 | history.merge(into: backgroundContext) 26 | viewContext.perform { 27 | history.merge(into: self.viewContext) 28 | } 29 | 30 | guard let lastTimestamp = history.last?.timestamp else { 31 | return 32 | } 33 | UserDefaults.standard.set(lastTimestamp, forKey: timestampKey) 34 | } 35 | 36 | } 37 | 38 | extension Collection where Element == NSPersistentHistoryTransaction { 39 | func merge(into context: NSManagedObjectContext) { 40 | forEach { transaction in 41 | guard let userInfo = transaction.objectIDNotification().userInfo else { return } 42 | NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [context]) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/persistence/PersistenceHistoryObserver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistenceHistoryObserver.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | import CoreData 8 | 9 | public class PersistenceHistoryObserver { 10 | private let container: NSPersistentContainer 11 | 12 | private lazy var historyQueue: OperationQueue = { 13 | let queue = OperationQueue() 14 | queue.maxConcurrentOperationCount = 1 15 | return queue 16 | }() 17 | 18 | public init(container: NSPersistentContainer) { 19 | self.container = container 20 | } 21 | 22 | public func start() { 23 | NotificationCenter.default.addObserver( 24 | self, 25 | selector: #selector(processStoreRemoteChanges), 26 | name: .NSPersistentStoreRemoteChange, 27 | object: self.container.persistentStoreCoordinator 28 | ) 29 | } 30 | 31 | @objc private func processStoreRemoteChanges(_ notification: Notification) { 32 | historyQueue.addOperation { [weak self] in 33 | self?.processPersistentHistory() 34 | } 35 | } 36 | 37 | @objc private func processPersistentHistory() { 38 | let context = self.container.newBackgroundContext() 39 | context.performAndWait { 40 | do { 41 | let merger = PersistenceHistoryMerger(backgroundContext: context, viewContext: LinkdingPersistenceController.shared.viewContext) 42 | try merger.mergeHistory() 43 | 44 | let cleaner = PersistenceHistoryCleaner(context: context) 45 | try cleaner.cleanPersistenceHistory() 46 | } catch { 47 | debugPrint("Error receiving persistence history.") 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/persistence/migrations/LinkdingModel1To2.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataMigration1To2.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | import CoreData 8 | 9 | class CoreDataMigration1To2: NSEntityMigrationPolicy { 10 | @objc func initInternalId() -> NSUUID { 11 | return NSUUID() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/settings/LinkdingSettingKeys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingSettingKeys.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public enum LinkdingSettingKeys: String { 9 | case bookmarkFilterArchived = "linkding.bookmarks.filter.archived" 10 | case bookmarkFilterUnread = "linkding.bookmarks.filter.unread" 11 | case bookmarkSortField = "linkding.bookmarks.sort.field" 12 | case bookmarkSortOrder = "linkding.bookmarks.sort.order" 13 | case bookmarkViewDescription = "linkding.bookmarks.view.description" 14 | case bookmarkViewTags = "linkding.bookmarks.view.tags" 15 | 16 | case settingsUrl = "linkding.settings.url" 17 | case settingsToken = "linkding.settings.token" 18 | case configComplete = "linkding.configured" 19 | 20 | case createBookmarkDefaultUnread = "linkding.create.default.unread" 21 | case createBookmarkDefaultShared = "linkding.create.default.shared" 22 | case createBookmarkDefaultArchived = "linkding.create.default.archived" 23 | 24 | case tagFilterOnlyUsed = "linkding.tags.filter.onlyused" 25 | case tagSortOrder = "linkding.tags.sort.order" 26 | case tagViewBookmarkDescription = "linkding.tags.view.bookmarkdescription" 27 | case tagViewBookmarkTags = "linkding.tags.view.bookmarktags" 28 | 29 | case syncRunning = "linkding.sync.running" 30 | case syncHadError = "linkding.sync.error" 31 | case syncErrorMessage = "linkding.sync.error.message" 32 | 33 | case persistenceHistoryLastTransactionTimestamp = "linkding.persistence.lastTransactionTimestamp" 34 | } 35 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/settings/LinkdingSettingsValidator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingSettingsValidator.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public class LinkdingSettingsValidator { 9 | public init() { 10 | } 11 | 12 | public func validateSettings() -> Bool { 13 | guard let url = AppStorageSupport.shared.sharedStore.string(forKey: LinkdingSettingKeys.settingsUrl.rawValue) else { 14 | return false 15 | } 16 | guard let secureToken = try? SecureSettingsSupport.getSecureSettingString(key: LinkdingSettingKeys.settingsToken.rawValue) else { 17 | return false 18 | } 19 | 20 | return url != "" && secureToken != "" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/sort/BookmarkSorter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarkSorter.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | class BookmarkSorter { 9 | private let sortField: SortField 10 | private let sortOrder: SortOrder 11 | 12 | init(sortField: SortField, sortOrder: SortOrder) { 13 | self.sortField = sortField 14 | self.sortOrder = sortOrder 15 | } 16 | 17 | func compareBookmark(a: LinkdingBookmarkEntity, b: LinkdingBookmarkEntity) -> Bool { 18 | let compareOrder = self.sortOrder == .ascending ? ComparisonResult.orderedAscending : ComparisonResult.orderedDescending 19 | 20 | switch self.sortField { 21 | case .url: 22 | return a.url.compare(b.url, options: .caseInsensitive) == compareOrder 23 | case .title: 24 | return a.displayTitle.compare(b.displayTitle, options: .caseInsensitive) == compareOrder 25 | case .description: 26 | return a.displayDescription.compare(b.displayDescription, options: .caseInsensitive) == compareOrder 27 | case .dateAdded: 28 | guard let dateA = a.dateAdded else { 29 | return true 30 | } 31 | guard let dateB = b.dateAdded else { 32 | return true 33 | } 34 | return dateA.compare(dateB) == compareOrder 35 | case .dateModified: 36 | guard let dateA = a.dateModified else { 37 | return true 38 | } 39 | guard let dateB = b.dateModified else { 40 | return true 41 | } 42 | return dateA.compare(dateB) == compareOrder 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/sort/SortField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SortField.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | enum SortField: String { 9 | case url, title, description, dateAdded, dateModified 10 | var id: Self { self } 11 | } 12 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/sort/SortOrder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SortOrder.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | enum SortOrder: String { 9 | case ascending, descending 10 | var id: Self { self } 11 | } 12 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/sync/LinkdingSyncClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingSyncClient.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public class LinkdingSyncClient: ObservableObject { 9 | private var apiClient: LinkdingApiClient 10 | 11 | private let tagStore: LinkdingTagStore 12 | private let tagRepository: LinkdingTagRepository 13 | 14 | private let bookmarkStore: LinkdingBookmarkStore 15 | private let bookmarkRepository: LinkdingBookmarkRepository 16 | 17 | public init(tagStore: LinkdingTagStore, bookmarkStore: LinkdingBookmarkStore) { 18 | self.tagStore = tagStore 19 | self.tagRepository = LinkdingTagRepository(tagStore: tagStore) 20 | self.bookmarkStore = bookmarkStore 21 | self.bookmarkRepository = LinkdingBookmarkRepository(bookmarkStore: bookmarkStore, tagStore: tagStore) 22 | 23 | let baseUrl = AppStorageSupport.shared.sharedStore.string(forKey: LinkdingSettingKeys.settingsUrl.rawValue) ?? "" 24 | let apiToken = (try? SecureSettingsSupport.getSecureSettingString(key: LinkdingSettingKeys.settingsToken.rawValue)) ?? "" 25 | self.apiClient = LinkdingApiClient(baseUrl: baseUrl, apiToken: apiToken) 26 | } 27 | 28 | private func syncRunning() -> Bool { 29 | guard let syncRunning = AppStorageSupport.shared.sharedStore.object(forKey: LinkdingSettingKeys.syncRunning.rawValue) as? Date else { 30 | return false 31 | } 32 | 33 | guard let syncThreshold = Calendar.current.date(byAdding: .minute, value: -1, to: Date.now) else { 34 | // Something is really odd here so probably no sync running 35 | return false 36 | } 37 | 38 | return syncThreshold < syncRunning 39 | } 40 | 41 | public func sync() async throws { 42 | if (self.syncRunning()) { 43 | return 44 | } 45 | 46 | AppStorageSupport.shared.sharedStore.set(false, forKey: LinkdingSettingKeys.syncHadError.rawValue) 47 | AppStorageSupport.shared.sharedStore.set("", forKey: LinkdingSettingKeys.syncErrorMessage.rawValue) 48 | AppStorageSupport.shared.sharedStore.setValue(Date.now, forKey: LinkdingSettingKeys.syncRunning.rawValue) 49 | do { 50 | try await self.pushNewTags() 51 | try await self.pushDeletedBookmarks() 52 | try await self.pushNewBookmarks() 53 | try await self.pushModifiedBookmarks() 54 | try await self.syncTags() 55 | try await self.syncBookmarks() 56 | } catch (let error) { 57 | AppStorageSupport.shared.sharedStore.set(true, forKey: LinkdingSettingKeys.syncHadError.rawValue) 58 | if let apiError = error as? LinkdingApiError { 59 | AppStorageSupport.shared.sharedStore.set(apiError.message, forKey: LinkdingSettingKeys.syncErrorMessage.rawValue) 60 | } 61 | AppStorageSupport.shared.sharedStore.set(nil, forKey: LinkdingSettingKeys.syncRunning.rawValue) 62 | throw error 63 | } 64 | AppStorageSupport.shared.sharedStore.set(nil, forKey: LinkdingSettingKeys.syncRunning.rawValue) 65 | } 66 | 67 | private func pushDeletedBookmarks() async throws { 68 | for deletedBookmarks in self.bookmarkStore.onlyLocallyDeletedBookmarks { 69 | try await self.deleteBookmark(bookmark: deletedBookmarks) 70 | } 71 | } 72 | 73 | private func pushNewBookmarks() async throws { 74 | for newBookmark in self.bookmarkStore.onlyNewBookmarks { 75 | try await self.createBookmark(bookmark: newBookmark) 76 | } 77 | } 78 | 79 | private func pushModifiedBookmarks() async throws { 80 | for modifiedBookmark in self.bookmarkStore.onlyModifiedBookmarks { 81 | try await self.updateBookmark(bookmark: modifiedBookmark) 82 | } 83 | } 84 | 85 | private func pushNewTags() async throws { 86 | for tag in self.tagStore.onlyLocalTags { 87 | let updatedTag = try await self.apiClient.createTag(name: tag.name) 88 | self.tagRepository.updateTag(entity: tag, updateData: TagModel(serverId: updatedTag.id, name: updatedTag.name, dateAdded: updatedTag.dateAdded)) 89 | } 90 | } 91 | 92 | private func syncTags() async throws { 93 | let tags = try await self.apiClient.loadTags() 94 | let updates = tags.map { 95 | TagModel(serverId: $0.id, name: $0.name, dateAdded: $0.dateAdded) 96 | } 97 | self.tagRepository.batchApplyChanges(models: updates) 98 | 99 | let allServerIds = self.tagStore.tags.map { $0.serverId } 100 | let deleteServerIds = self.tagStore.tags 101 | .filter { !allServerIds.contains($0.serverId) } 102 | .map { $0.serverId } 103 | self.tagRepository.batchDeleteServerIds(serverIds: deleteServerIds) 104 | } 105 | 106 | private func syncBookmarks() async throws { 107 | let bookmarks = try await self.apiClient.loadBookmarks() 108 | for bookmarkDto in bookmarks { 109 | await self.bookmarkRepository.createOrUpdateBookmarkFromServer( 110 | serverId: bookmarkDto.id, 111 | url: bookmarkDto.url, 112 | title: bookmarkDto.title, 113 | urlDescription: bookmarkDto.description, 114 | websiteTitle: bookmarkDto.websiteTitle, 115 | websiteDescription: bookmarkDto.websiteDescription, 116 | isArchived: bookmarkDto.isArchived, 117 | unread: bookmarkDto.unread, 118 | shared: bookmarkDto.shared, 119 | dateAdded: bookmarkDto.dateAdded, 120 | dateModified: bookmarkDto.dateModified, 121 | tags: bookmarkDto.tags 122 | ) 123 | } 124 | let allServerIds = bookmarks.map { 125 | $0.id 126 | } 127 | for bookmark in self.bookmarkStore.bookmarks { 128 | if (!allServerIds.contains(bookmark.serverId)) { 129 | await self.bookmarkRepository.deleteBookmarkByServerId(serverId: bookmark.serverId) 130 | } 131 | } 132 | } 133 | 134 | private func deleteBookmark(bookmark: LinkdingBookmarkEntity) async throws { 135 | if (!bookmark.localOnly) { 136 | try await self.apiClient.deleteBookmark(serverId: bookmark.serverId) 137 | } 138 | await self.bookmarkRepository.deleteBookmark(bookmark: bookmark) 139 | } 140 | 141 | private func createBookmark(bookmark: LinkdingBookmarkEntity) async throws { 142 | let createdBookmark = try await self.apiClient.createBookmark( 143 | url: bookmark.url, 144 | title: bookmark.title, 145 | description: bookmark.urlDescription, 146 | isArchived: bookmark.isArchived, 147 | unread: bookmark.unread, 148 | shared: bookmark.shared, 149 | tagNames: bookmark.tagNames 150 | ) 151 | await self.bookmarkRepository.updateExistingBookmarkFromServer( 152 | bookmark: bookmark, 153 | serverId: createdBookmark.id, 154 | url: createdBookmark.url, 155 | title: createdBookmark.title, 156 | urlDescription: createdBookmark.description, 157 | websiteTitle: createdBookmark.websiteTitle, 158 | websiteDescription: createdBookmark.websiteDescription, 159 | isArchived: createdBookmark.isArchived, 160 | unread: createdBookmark.unread, 161 | shared: createdBookmark.shared, 162 | dateAdded: createdBookmark.dateAdded, 163 | dateModified: createdBookmark.dateModified, 164 | tags: createdBookmark.tags 165 | ) 166 | } 167 | 168 | private func updateBookmark(bookmark: LinkdingBookmarkEntity) async throws { 169 | let bookmarkFromServer = try await self.apiClient.updateBookmark( 170 | serverId: bookmark.serverId, 171 | url: bookmark.url, 172 | title: bookmark.title, 173 | description: bookmark.urlDescription, 174 | isArchived: bookmark.isArchived, 175 | unread: bookmark.unread, 176 | shared: bookmark.shared, 177 | tagNames: bookmark.tagNames 178 | ) 179 | await self.bookmarkRepository.updateExistingBookmarkFromServer( 180 | bookmark: bookmark, 181 | serverId: bookmarkFromServer.id, 182 | url: bookmarkFromServer.url, 183 | title: bookmarkFromServer.title, 184 | urlDescription: bookmarkFromServer.description, 185 | websiteTitle: bookmarkFromServer.websiteTitle, 186 | websiteDescription: bookmarkFromServer.websiteDescription, 187 | isArchived: bookmarkFromServer.isArchived, 188 | unread: bookmarkFromServer.unread, 189 | shared: bookmarkFromServer.shared, 190 | dateAdded: bookmarkFromServer.dateAdded, 191 | dateModified: bookmarkFromServer.dateModified, 192 | tags: bookmarkFromServer.tags 193 | ) 194 | } 195 | 196 | public func syncSingleBookmark(bookmark: LinkdingBookmarkEntity) async throws { 197 | do { 198 | if (bookmark.locallyDeleted) { 199 | try await self.deleteBookmark(bookmark: bookmark) 200 | return 201 | } 202 | 203 | if (bookmark.localOnly) { 204 | try await self.createBookmark(bookmark: bookmark) 205 | return 206 | } 207 | 208 | if (bookmark.locallyModified) { 209 | try await self.updateBookmark(bookmark: bookmark) 210 | return 211 | } 212 | 213 | // Nothing to be done 214 | } catch (let error) { 215 | AppStorageSupport.shared.sharedStore.set(true, forKey: LinkdingSettingKeys.syncHadError.rawValue) 216 | throw error 217 | } 218 | } 219 | 220 | public func syncSingleTag(tag: LinkdingTagEntity) async throws { 221 | do { 222 | let updatedTag = try await self.apiClient.createTag(name: tag.name) 223 | self.tagRepository.updateTag(entity: tag, updateData: TagModel(serverId: updatedTag.id, name: updatedTag.name, dateAdded: updatedTag.dateAdded)) 224 | } catch (let error) { 225 | AppStorageSupport.shared.sharedStore.set(true, forKey: LinkdingSettingKeys.syncHadError.rawValue) 226 | throw error 227 | } 228 | } 229 | } 230 | 231 | extension LinkdingSyncClient: BackendSupportClientProtocol { 232 | public func isBackendAvailable() async -> Bool { 233 | do { 234 | return try await self.apiClient.apiAvailable() 235 | } catch (_) { 236 | return false 237 | } 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/ui/configuration/LinkdingSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingSettingsView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | public struct LinkdingSettingsView: View { 9 | var secureToken: Binding = Binding( 10 | get: { 11 | guard let value = try? SecureSettingsSupport.getSecureSettingString(key: LinkdingSettingKeys.settingsToken.rawValue) else { 12 | return "" 13 | } 14 | return value 15 | }, 16 | set: { 17 | try? SecureSettingsSupport.setSecureSettingString(key: LinkdingSettingKeys.settingsToken.rawValue, value: $0) 18 | } 19 | ) 20 | @AppStorage(LinkdingSettingKeys.settingsUrl.rawValue, store: AppStorageSupport.shared.sharedStore) var url: String = "" 21 | @AppStorage(LinkdingSettingKeys.createBookmarkDefaultArchived.rawValue, store: AppStorageSupport.shared.sharedStore) var defaultArchived: Bool = false 22 | @AppStorage(LinkdingSettingKeys.createBookmarkDefaultUnread.rawValue, store: AppStorageSupport.shared.sharedStore) var defaultUnread: Bool = false 23 | @AppStorage(LinkdingSettingKeys.createBookmarkDefaultShared.rawValue, store: AppStorageSupport.shared.sharedStore) var defaultShared: Bool = false 24 | 25 | @State var urlError: Bool = false 26 | @State var tokenError: Bool = false 27 | 28 | public init() { 29 | } 30 | 31 | public var body: some View { 32 | Section("Credentials") { 33 | VStack { 34 | TextField(text: self.$url) { 35 | Text("URL") 36 | } 37 | .keyboardType(.URL) 38 | .disableAutocorrection(true) 39 | .autocapitalization(.none) 40 | .onChange(of: self.url, perform: { newValue in 41 | if (newValue == "") { 42 | self.urlError = true 43 | } else { 44 | self.urlError = false 45 | } 46 | }) 47 | if (self.urlError) { 48 | Text("URL is required.") 49 | .foregroundColor(.red) 50 | } 51 | } 52 | VStack { 53 | SecureField(text: self.secureToken) { 54 | Text("Token") 55 | } 56 | .textContentType(.password) 57 | .disableAutocorrection(true) 58 | .autocapitalization(.none) 59 | .onChange(of: self.secureToken.wrappedValue, perform: { newValue in 60 | if (newValue == "") { 61 | self.tokenError = true 62 | } else { 63 | self.tokenError = false 64 | } 65 | }) 66 | if (self.tokenError) { 67 | Text("Token is required.") 68 | .foregroundColor(.red) 69 | } 70 | } 71 | } 72 | .onAppear() { 73 | self.urlError = self.url == "" 74 | self.tokenError = self.secureToken.wrappedValue == "" 75 | } 76 | Section("Default flags") { 77 | Toggle(isOn: self.$defaultUnread, label: { Text("Default flag for unread") }) 78 | Toggle(isOn: self.$defaultShared, label: { Text("Default flag for shared") }) 79 | } 80 | } 81 | } 82 | 83 | 84 | struct LinkdingSettingsView_Previews: PreviewProvider { 85 | static var previews: some View { 86 | List { 87 | LinkdingSettingsView() 88 | } 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/ui/dashboard/LinkdingDashboardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingDashboardView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | public struct LinkdingDashboardView: View, BaseIntegrationDashboard { 9 | @AppStorage(SharedSettingKeys.useExperimentalDashboard.rawValue, store: AppStorageSupport.shared.sharedStore) var useExperimentalDashboard: Bool = false 10 | 11 | var openConfig: Binding 12 | 13 | @Environment(\.scenePhase) var scenePhase 14 | let persistenceController = LinkdingPersistenceController.shared 15 | 16 | var tagStore: LinkdingTagStore = LinkdingTagStore() 17 | var bookmarkStore: LinkdingBookmarkStore = LinkdingBookmarkStore() 18 | 19 | public init(openConfig: Binding) { 20 | self.openConfig = openConfig 21 | } 22 | 23 | var selectedView: some View { 24 | ZStack { 25 | if self.useExperimentalDashboard { 26 | Dashboard(bookmarkStore: self, tagStore: self, syncService: self, title: "Linkding") 27 | } else { 28 | TabView { 29 | LinkdingBookmarkTabView(openConfig: self.openConfig) 30 | .tabItem { 31 | Image(systemName: "bookmark") 32 | Text("Bookmarks") 33 | } 34 | LinkdingTagsTabView(openConfig: self.openConfig) 35 | .tabItem { 36 | Image(systemName: "tag") 37 | Text("Tags") 38 | } 39 | } 40 | } 41 | } 42 | } 43 | 44 | public var body: some View { 45 | self.selectedView 46 | .navigationBarTitleDisplayMode(.inline) 47 | .environment(\.managedObjectContext, persistenceController.viewContext) 48 | .environmentObject(self.tagStore) 49 | .environmentObject(self.bookmarkStore) 50 | .onAppear() { 51 | LinkdingPersistenceController.shared.setViewContextData(name: "viewContext", author: "BookmarkCompanion") 52 | 53 | // Migration of linkding token access to app group 54 | AccessTokenMigrationToAppGroup().migrate() 55 | 56 | Task { 57 | let sync = LinkdingSyncClient(tagStore: self.tagStore, bookmarkStore: self.bookmarkStore) 58 | try await sync.sync() 59 | } 60 | } 61 | .onChange(of: scenePhase, perform: { newPhase in 62 | if newPhase == .active { 63 | let sync = LinkdingSyncClient(tagStore: self.tagStore, bookmarkStore: self.bookmarkStore) 64 | Task { 65 | try await sync.sync() 66 | } 67 | } 68 | }) 69 | } 70 | } 71 | 72 | extension LinkdingDashboardView: BookmarkStore { 73 | public typealias ID = UUID 74 | 75 | public func filter(text: String?, filter: BookmarkStoreFilter?) -> [Bookmark] { 76 | let unreadOnly = filter == .unread ? true : false 77 | return self.bookmarkStore 78 | .filtered(showArchived: false, showUnreadOnly: unreadOnly, filterText: text ?? "") 79 | .map { self.convertToBookmark(bookmark: $0) } 80 | } 81 | 82 | public func byTag(tag: Tag) -> [Bookmark] { 83 | return self.bookmarkStore 84 | .allBookmarks 85 | .filter { $0.tagNames.contains(tag.name) } 86 | .map { self.convertToBookmark(bookmark: $0) } 87 | } 88 | 89 | private func convertToBookmark(bookmark: LinkdingBookmarkEntity) -> Bookmark { 90 | return Bookmark(id: bookmark.internalId, title: bookmark.displayTitle, url: bookmark.url, description: bookmark.displayDescription, tags: bookmark.tags) 91 | } 92 | } 93 | 94 | extension LinkdingDashboardView: TagStore { 95 | public func filter(text: String?) -> [Tag] { 96 | return self.tagStore 97 | .usedTags 98 | .map { Tag(id: $0.id, name: $0.name) } 99 | } 100 | } 101 | 102 | extension LinkdingDashboardView: SyncService { 103 | func runFullSync() async { 104 | let sync = LinkdingSyncClient(tagStore: self.tagStore, bookmarkStore: self.bookmarkStore) 105 | try? await sync.sync() 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/ui/dashboard/bookmarks/LinkdingBookmarkTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingBookmarkTabView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct LinkdingBookmarkTabView: View { 9 | @EnvironmentObject var bookmarkStore: LinkdingBookmarkStore 10 | @EnvironmentObject var tagStore: LinkdingTagStore 11 | 12 | @AppStorage(LinkdingSettingKeys.bookmarkFilterArchived.rawValue, store: AppStorageSupport.shared.sharedStore) var showArchived: Bool = false 13 | @AppStorage(LinkdingSettingKeys.bookmarkFilterUnread.rawValue, store: AppStorageSupport.shared.sharedStore) var showUnread: Bool = false 14 | @AppStorage(LinkdingSettingKeys.syncHadError.rawValue, store: AppStorageSupport.shared.sharedStore) var syncHadError: Bool = false 15 | @AppStorage(LinkdingSettingKeys.syncErrorMessage.rawValue, store: AppStorageSupport.shared.sharedStore) var syncErrorMessage: String = "" 16 | 17 | @AppStorage(LinkdingSettingKeys.bookmarkSortField.rawValue, store: AppStorageSupport.shared.sharedStore) var sortField: SortField = .url 18 | @AppStorage(LinkdingSettingKeys.bookmarkSortOrder.rawValue, store: AppStorageSupport.shared.sharedStore) var sortOrder: SortOrder = .ascending 19 | 20 | @AppStorage(LinkdingSettingKeys.bookmarkViewDescription.rawValue, store: AppStorageSupport.shared.sharedStore) var viewDescription: Bool = true 21 | @AppStorage(LinkdingSettingKeys.bookmarkViewTags.rawValue, store: AppStorageSupport.shared.sharedStore) var viewTags: Bool = true 22 | 23 | @State var filterViewOpen: Bool = false 24 | @State var createBookmarkOpen: Bool = false 25 | @State var bookmarkToEdit: Bookmark? = nil 26 | 27 | @Binding var openConfig: Bool 28 | 29 | var body: some View { 30 | NavigationStack { 31 | VStack { 32 | BookmarkListView( 33 | bookmarkStore: self, 34 | showTags: self.viewTags, 35 | showDescription: self.viewDescription, 36 | tapHandler: { bookmark in 37 | self.bookmarkToEdit = bookmark 38 | }, 39 | deleteHandler: { bookmark in 40 | self.deleteBookmark(bookmark: bookmark) 41 | }, 42 | preListView: { 43 | if self.syncHadError { 44 | Section() { 45 | SyncErrorView(errorDetails: self.syncErrorMessage) 46 | } 47 | } 48 | } 49 | ) 50 | .navigationTitle("Bookmarks") 51 | .toolbar { 52 | ToolbarItem(placement: .navigationBarLeading) { 53 | ConfigurationButton(actionHandler: { 54 | self.openConfig.toggle() 55 | }) 56 | } 57 | ToolbarItemGroup(placement: .navigationBarTrailing) { 58 | Button(action: { 59 | self.filterViewOpen = true 60 | }) { 61 | Image(systemName: "slider.horizontal.3") 62 | } 63 | Button(action: { 64 | self.createBookmarkOpen = true 65 | }) { 66 | Image(systemName: "plus") 67 | } 68 | } 69 | } 70 | .refreshable { 71 | let syncClient = LinkdingSyncClient(tagStore: self.tagStore, bookmarkStore: self.bookmarkStore) 72 | try? await syncClient.sync() 73 | } 74 | } 75 | } 76 | .sheet(isPresented: self.$filterViewOpen) { 77 | LinkdingBookmarkTabSettingsView() 78 | } 79 | .sheet(isPresented: self.$createBookmarkOpen) { 80 | LinkdingCreateBookmarkView() 81 | } 82 | .sheet(item: self.$bookmarkToEdit) { bookmark in 83 | let entity = self.bookmarkStore.getByInternalId(internalId: bookmark.id) 84 | if entity != nil { 85 | BookmarkEditor(bookmark: entity!) 86 | } 87 | } 88 | } 89 | 90 | private func deleteBookmark(bookmark: Bookmark) { 91 | guard let entity = self.bookmarkStore.getByInternalId(internalId: bookmark.id) else { 92 | return 93 | } 94 | let repository = LinkdingBookmarkRepository(bookmarkStore: self.bookmarkStore, tagStore: self.tagStore) 95 | repository.markAsDeleted(bookmark: entity) 96 | } 97 | 98 | } 99 | 100 | extension LinkdingBookmarkTabView: FilteredBookmarkStore { 101 | func filter(text: String) -> [Bookmark] { 102 | let bookmarkSorter = BookmarkSorter(sortField: self.sortField, sortOrder: self.sortOrder) 103 | return self.bookmarkStore.filtered(showArchived: self.showArchived, showUnreadOnly: self.showUnread, filterText: text) 104 | .sorted { 105 | bookmarkSorter.compareBookmark(a: $0, b: $1) 106 | } 107 | .map { 108 | Bookmark(id: $0.internalId, title: $0.displayTitle, url: $0.url, description: $0.displayDescription, tags: $0.tags) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/ui/dashboard/bookmarks/create/LinkdingCreateBookmarkView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingCreateBookmarkView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct LinkdingCreateBookmarkView: View { 9 | @Environment(\.presentationMode) private var presentationMode 10 | @EnvironmentObject var bookmarkStore: LinkdingBookmarkStore 11 | @EnvironmentObject var tagStore: LinkdingTagStore 12 | 13 | @AppStorage(LinkdingSettingKeys.createBookmarkDefaultArchived.rawValue, store: AppStorageSupport.shared.sharedStore) var defaultArchived: Bool = false 14 | @AppStorage(LinkdingSettingKeys.createBookmarkDefaultUnread.rawValue, store: AppStorageSupport.shared.sharedStore) var defaultUnread: Bool = false 15 | @AppStorage(LinkdingSettingKeys.createBookmarkDefaultShared.rawValue, store: AppStorageSupport.shared.sharedStore) var defaultShared: Bool = false 16 | 17 | @State var url: String = "" 18 | @State var title: String = "" 19 | @State var description: String = "" 20 | @State var isArchived: Bool = false 21 | @State var unread: Bool = false 22 | @State var shared: Bool = false 23 | @State var tags = Set() 24 | 25 | @State var selectTagsOpen: Bool = false 26 | @State var linkdingAvailable: Bool = false 27 | @State var requestInProgress: Bool = false 28 | 29 | var body: some View { 30 | NavigationStack { 31 | Form { 32 | Section( 33 | content: { 34 | TextField(text: $url) { 35 | Text("URL") 36 | } 37 | TextField(text: $title) { 38 | Text("Title") 39 | } 40 | TextField(text: $description) { 41 | Text("Description") 42 | } 43 | }, 44 | header: { 45 | Text( "Bookmark") 46 | }, 47 | footer: { 48 | if !self.linkdingAvailable { 49 | Text("Linkding backend is not available. Bookmark is stored on your device.") 50 | .font(.caption) 51 | .foregroundColor(.red) 52 | } 53 | } 54 | ) 55 | Section("Flags") { 56 | Toggle(isOn: $unread) { 57 | Text("Unread") 58 | } 59 | Toggle(isOn: $shared) { 60 | Text("Shared") 61 | } 62 | } 63 | Section(content: { 64 | if (self.tags.count > 0) { 65 | ForEach(self.tags.map { $0 }) { tag in 66 | Text(tag.name) 67 | } 68 | } else { 69 | Text("No tags selected") 70 | } 71 | }, header: { 72 | HStack { 73 | Text("Tags") 74 | Spacer() 75 | Button(action: { 76 | self.selectTagsOpen = true 77 | }) { 78 | Text("Select tags") 79 | } 80 | .font(.caption) 81 | .buttonStyle(.borderless) 82 | } 83 | }) 84 | } 85 | .navigationBarTitle("Create Bookmark") 86 | .toolbar { 87 | ToolbarItemGroup(placement: .navigationBarTrailing) { 88 | if self.requestInProgress { 89 | ProgressView() 90 | } else { 91 | Button(action: { 92 | if (self.url != "") { 93 | self.requestInProgress = true 94 | let repository = LinkdingBookmarkRepository(bookmarkStore: self.bookmarkStore, tagStore: self.tagStore) 95 | let bookmark = repository.createNewBookmark(url: self.url, title: self.title, description: self.description, isArchived: self.isArchived, unread: self.unread, shared: self.shared, tags: self.tags.map { $0.name }) 96 | let syncClient = LinkdingSyncClient(tagStore: self.tagStore, bookmarkStore: self.bookmarkStore) 97 | Task { 98 | try await syncClient.syncSingleBookmark(bookmark: bookmark) 99 | self.requestInProgress = false 100 | self.presentationMode.wrappedValue.dismiss() 101 | } 102 | } 103 | }) { 104 | Image(systemName: "tray.and.arrow.down") 105 | } 106 | } 107 | } 108 | ToolbarItemGroup(placement: .navigationBarLeading) { 109 | Button(action: { 110 | self.presentationMode.wrappedValue.dismiss() 111 | }) { 112 | Image(systemName: "xmark") 113 | } 114 | } 115 | } 116 | .sheet(isPresented: self.$selectTagsOpen) { 117 | SelectTagsView(selectedTags: self.$tags) 118 | } 119 | .onAppear() { 120 | self.isArchived = self.defaultArchived 121 | self.unread = self.defaultUnread 122 | self.shared = self.defaultShared 123 | let syncClient = LinkdingSyncClient(tagStore: self.tagStore, bookmarkStore: self.bookmarkStore) 124 | Task { 125 | if await syncClient.isBackendAvailable() { 126 | self.linkdingAvailable = true 127 | } else { 128 | self.linkdingAvailable = false 129 | } 130 | } 131 | } 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/ui/dashboard/bookmarks/create/SelectTagsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelectTagsView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct SelectTagsView: View { 9 | @EnvironmentObject var tagStore: LinkdingTagStore 10 | @Environment(\.presentationMode) private var presentationMode 11 | @Environment(\.dismissSearch) private var dismissSearch 12 | 13 | @Binding var selectedTags: Set 14 | 15 | var body: some View { 16 | NavigationStack { 17 | CommonSelectListView( 18 | items: self.tagStore.tags, 19 | selectedItems: self.$selectedTags, 20 | createNotFoundHandler: self 21 | ) 22 | .navigationBarTitle("Select tags") 23 | .toolbar { 24 | ToolbarItemGroup(placement: .navigationBarTrailing) { 25 | Button(action: { 26 | self.presentationMode.wrappedValue.dismiss() 27 | }) { 28 | Text("Close") 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | 36 | extension SelectTagsView: CreateNotFoundItemHandler { 37 | func createItem(text: String) { 38 | let repository = LinkdingTagRepository(tagStore: self.tagStore) 39 | let createdTag = repository.createTag(tag: TagModel(name: text)) 40 | self.selectedTags.insert(createdTag) 41 | self.dismissSearch() 42 | } 43 | } 44 | 45 | extension LinkdingTagEntity: CommonListItem { 46 | public func getDisplayText() -> String { 47 | return self.name 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/ui/dashboard/bookmarks/edit/BookmarkEditor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarkEditor.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct BookmarkEditor: View { 9 | @Environment(\.presentationMode) private var presentationMode 10 | @EnvironmentObject var bookmarkStore: LinkdingBookmarkStore 11 | @EnvironmentObject var tagStore: LinkdingTagStore 12 | 13 | @ObservedObject var bookmark: LinkdingBookmarkEntity 14 | 15 | @State var url: String = "" 16 | @State var title: String = "" 17 | @State var description: String = "" 18 | @State var isArchived: Bool = false 19 | @State var unread: Bool = false 20 | @State var shared: Bool = false 21 | @State var tags = Set() 22 | 23 | @State var selectTagsOpen: Bool = false 24 | 25 | var body: some View { 26 | NavigationStack { 27 | Form { 28 | Section("Bookmark") { 29 | TextField(text: $url) { 30 | Text("URL") 31 | } 32 | TextField(text: $title) { 33 | Text(self.bookmark.websiteTitle ?? "Title") 34 | } 35 | TextField(text: $description) { 36 | Text(self.bookmark.websiteDescription ?? "Description") 37 | } 38 | } 39 | Section("Flags") { 40 | Toggle(isOn: $unread) { 41 | Text("Unread") 42 | } 43 | Toggle(isOn: $shared) { 44 | Text("Shared") 45 | } 46 | } 47 | if let urlObj = URL(string: self.url) { 48 | ShareLink("Share bookmark", item: urlObj) 49 | } 50 | Section(content: { 51 | if (self.tags.count > 0) { 52 | ForEach(self.tags.map { $0 }) { tag in 53 | Text(tag.name) 54 | } 55 | } else { 56 | Text("No tags selected") 57 | } 58 | }, header: { 59 | HStack { 60 | Text("Tags") 61 | Spacer() 62 | Button(action: { 63 | self.selectTagsOpen = true 64 | }) { 65 | Text("Select tags") 66 | } 67 | .font(.caption) 68 | .buttonStyle(.borderless) 69 | } 70 | }) 71 | } 72 | .onAppear() { 73 | self.url = self.bookmark.url 74 | self.title = self.bookmark.title 75 | self.description = self.bookmark.urlDescription 76 | self.isArchived = self.bookmark.isArchived 77 | self.unread = self.bookmark.unread 78 | self.shared = self.bookmark.shared 79 | self.tags = Set(self.bookmark.tagEntities.map { $0 }) 80 | } 81 | .navigationBarTitle("Edit Bookmark") 82 | .toolbar { 83 | ToolbarItemGroup(placement: .navigationBarLeading) { 84 | Button(action: { 85 | self.presentationMode.wrappedValue.dismiss() 86 | }) { 87 | Image(systemName: "xmark") 88 | } 89 | } 90 | ToolbarItemGroup(placement: .navigationBarTrailing) { 91 | Button(action: { 92 | if (self.url != "") { 93 | let repository = LinkdingBookmarkRepository(bookmarkStore: self.bookmarkStore, tagStore: self.tagStore) 94 | let bookmark = repository.updateBookmark(bookmark: self.bookmark, url: self.url, title: self.title, description: self.description, isArchived: self.isArchived, unread: self.unread, shared: self.shared, tags: self.tags.map { $0.name }) 95 | let syncClient = LinkdingSyncClient(tagStore: self.tagStore, bookmarkStore: self.bookmarkStore) 96 | Task { 97 | try await syncClient.syncSingleBookmark(bookmark: bookmark) 98 | } 99 | self.presentationMode.wrappedValue.dismiss() 100 | } 101 | }) { 102 | Image(systemName: "tray.and.arrow.down") 103 | } 104 | } 105 | } 106 | .sheet(isPresented: self.$selectTagsOpen) { 107 | SelectTagsView(selectedTags: self.$tags) 108 | } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/ui/dashboard/bookmarks/filter/LinkdingBookmarkTabSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingBookmarkTabFilterView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct LinkdingBookmarkTabSettingsView: View { 9 | @Environment(\.presentationMode) private var presentationMode 10 | 11 | @AppStorage(LinkdingSettingKeys.bookmarkFilterArchived.rawValue, store: AppStorageSupport.shared.sharedStore) var showArchived: Bool = false 12 | @AppStorage(LinkdingSettingKeys.bookmarkFilterUnread.rawValue, store: AppStorageSupport.shared.sharedStore) var showUnread: Bool = false 13 | @AppStorage(LinkdingSettingKeys.bookmarkSortField.rawValue, store: AppStorageSupport.shared.sharedStore) var sortField: SortField = .url 14 | @AppStorage(LinkdingSettingKeys.bookmarkSortOrder.rawValue, store: AppStorageSupport.shared.sharedStore) var sortOrder: SortOrder = .ascending 15 | @AppStorage(LinkdingSettingKeys.bookmarkViewDescription.rawValue, store: AppStorageSupport.shared.sharedStore) var viewDescription: Bool = true 16 | @AppStorage(LinkdingSettingKeys.bookmarkViewTags.rawValue, store: AppStorageSupport.shared.sharedStore) var viewTags: Bool = true 17 | 18 | var body: some View { 19 | NavigationStack { 20 | VStack { 21 | Form { 22 | Section("Filter") { 23 | Toggle("Only show unread", isOn: self.$showUnread) 24 | } 25 | Section("Sort") { 26 | Picker("Sort by field", selection: self.$sortField) { 27 | Text("URL").tag(SortField.url) 28 | Text("Title").tag(SortField.title) 29 | Text("Description").tag(SortField.description) 30 | Text("Date added").tag(SortField.dateAdded) 31 | Text("Date modified").tag(SortField.dateModified) 32 | } 33 | Picker("Sort order", selection: self.$sortOrder) { 34 | Text("Ascending").tag(SortOrder.ascending) 35 | Text("Descending").tag(SortOrder.descending) 36 | } 37 | } 38 | Section("View") { 39 | Toggle("Show description", isOn: self.$viewDescription) 40 | Toggle("Show tags", isOn: self.$viewTags) 41 | } 42 | } 43 | } 44 | .navigationTitle("Bookmark Settings") 45 | .toolbar { 46 | ToolbarItem(placement: .navigationBarTrailing) { 47 | Button(action: { 48 | self.presentationMode.wrappedValue.dismiss() 49 | }) { 50 | Text("Close") 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/ui/dashboard/tags/CreateTagView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateTagView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct CreateTagView: View { 9 | @Environment(\.presentationMode) private var presentationMode 10 | 11 | @EnvironmentObject private var tagStore: LinkdingTagStore 12 | @EnvironmentObject private var bookmarkStore: LinkdingBookmarkStore 13 | 14 | @State var name: String = "" 15 | 16 | var body: some View { 17 | NavigationView { 18 | Form { 19 | TextField(text: self.$name) { 20 | Text("Name") 21 | } 22 | } 23 | .navigationTitle("Create tag") 24 | .toolbar { 25 | ToolbarItemGroup(placement: .navigationBarTrailing) { 26 | Button(action: { 27 | if self.name != "" { 28 | let repository = LinkdingTagRepository(tagStore: self.tagStore) 29 | let tag = repository.createTag(tag: TagModel(name: self.name)) 30 | let syncClient = LinkdingSyncClient(tagStore: self.tagStore, bookmarkStore: self.bookmarkStore) 31 | Task { 32 | try await syncClient.syncSingleTag(tag: tag) 33 | self.presentationMode.wrappedValue.dismiss() 34 | } 35 | } 36 | }) { 37 | Image(systemName: "tray.and.arrow.down") 38 | } 39 | } 40 | ToolbarItemGroup(placement: .navigationBarLeading) { 41 | Button(action: { 42 | self.presentationMode.wrappedValue.dismiss() 43 | }) { 44 | Image(systemName: "xmark") 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/ui/dashboard/tags/FilterTagView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilterTagView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct FilterTagView: View { 9 | @Environment(\.presentationMode) private var presentationMode 10 | 11 | @AppStorage(LinkdingSettingKeys.tagFilterOnlyUsed.rawValue, store: AppStorageSupport.shared.sharedStore) var onlyUsed: Bool = false 12 | @AppStorage(LinkdingSettingKeys.tagSortOrder.rawValue, store: AppStorageSupport.shared.sharedStore) var sortOrder: SortOrder = .ascending 13 | @AppStorage(LinkdingSettingKeys.tagViewBookmarkDescription.rawValue, store: AppStorageSupport.shared.sharedStore) var viewBookmarkDescription: Bool = false 14 | @AppStorage(LinkdingSettingKeys.tagViewBookmarkTags.rawValue, store: AppStorageSupport.shared.sharedStore) var viewBookmarkTags: Bool = false 15 | 16 | var body: some View { 17 | NavigationView { 18 | VStack { 19 | Form { 20 | Section("Filter") { 21 | Toggle("Hide unused tags", isOn: self.$onlyUsed) 22 | } 23 | Section("Sort") { 24 | Picker("Sort order", selection: self.$sortOrder) { 25 | Text("Ascending").tag(SortOrder.ascending) 26 | Text("Descending").tag(SortOrder.descending) 27 | } 28 | } 29 | Section("View") { 30 | Toggle("Show bookmark description", isOn: self.$viewBookmarkDescription) 31 | Toggle("Show bookmark tags", isOn: self.$viewBookmarkTags) 32 | } 33 | } 34 | } 35 | .navigationTitle("Tag Settings") 36 | .toolbar { 37 | ToolbarItem(placement: .navigationBarTrailing) { 38 | Button(action: { 39 | self.presentationMode.wrappedValue.dismiss() 40 | }) { 41 | Text("Close") 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/ui/dashboard/tags/LinkdingTagBookmarksView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingTagBookmarksView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct LinkdingTagBookmarksView: View { 9 | @EnvironmentObject var bookmarkStore: LinkdingBookmarkStore 10 | 11 | @AppStorage(LinkdingSettingKeys.bookmarkSortField.rawValue, store: AppStorageSupport.shared.sharedStore) var sortField: SortField = .url 12 | @AppStorage(LinkdingSettingKeys.bookmarkSortOrder.rawValue, store: AppStorageSupport.shared.sharedStore) var sortOrder: SortOrder = .ascending 13 | @AppStorage(LinkdingSettingKeys.tagViewBookmarkDescription.rawValue, store: AppStorageSupport.shared.sharedStore) var viewBookmarkDescription: Bool = false 14 | @AppStorage(LinkdingSettingKeys.tagViewBookmarkTags.rawValue, store: AppStorageSupport.shared.sharedStore) var viewBookmarkTags: Bool = false 15 | 16 | @State var bookmarkToEdit: Bookmark? = nil 17 | 18 | var tag: LinkdingTagEntity? 19 | var title: String 20 | 21 | var body: some View { 22 | VStack { 23 | BookmarkListView( 24 | bookmarkStore: self, 25 | showTags: self.viewBookmarkTags, 26 | showDescription: self.viewBookmarkDescription, 27 | enableDelete: false, 28 | tapHandler: { bookmark in 29 | self.bookmarkToEdit = bookmark 30 | } 31 | ) 32 | .navigationTitle("Bookmarks for \(self.title)") 33 | .sheet(item: self.$bookmarkToEdit) { bookmark in 34 | let entity = self.bookmarkStore.getByInternalId(internalId: bookmark.id) 35 | if entity != nil { 36 | BookmarkEditor(bookmark: entity!) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | extension LinkdingTagBookmarksView: FilteredBookmarkStore { 44 | func filter(text: String) -> [Bookmark] { 45 | let bookmarkSorter = BookmarkSorter(sortField: self.sortField, sortOrder: self.sortOrder) 46 | let bookmarks = self.tag != nil ? self.tag!.bookmarks : self.bookmarkStore.untagged 47 | return self.bookmarkStore.filtered(showArchived: true, showUnreadOnly: false, filterText: text) 48 | .filter { 49 | bookmarks.contains($0) 50 | } 51 | .sorted { 52 | bookmarkSorter.compareBookmark(a: $0, b: $1) 53 | } 54 | .map { 55 | Bookmark(id: $0.internalId, title: $0.displayTitle, url: $0.url, description: $0.displayDescription, tags: $0.tags) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /CompanionApplication/integrations/linkding/ui/dashboard/tags/LinkdingTagsTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkdingTagsTabView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | enum TagMenuItemType { 9 | case untagged 10 | case folder 11 | case tag 12 | } 13 | 14 | struct TagViewMenuItem: Identifiable, Hashable { 15 | var id: Self { self } 16 | var name: String 17 | var type: TagMenuItemType 18 | var badge: Int? 19 | var tag: LinkdingTagEntity? 20 | var children: [TagViewMenuItem]? 21 | } 22 | 23 | struct LinkdingTagsTabView: View { 24 | @EnvironmentObject var tagStore: LinkdingTagStore 25 | @EnvironmentObject var bookmarkStore: LinkdingBookmarkStore 26 | 27 | @AppStorage(LinkdingSettingKeys.syncHadError.rawValue, store: AppStorageSupport.shared.sharedStore) var syncHadError: Bool = false 28 | @AppStorage(LinkdingSettingKeys.syncErrorMessage.rawValue, store: AppStorageSupport.shared.sharedStore) var syncErrorMessage: String = "" 29 | @AppStorage(LinkdingSettingKeys.tagFilterOnlyUsed.rawValue, store: AppStorageSupport.shared.sharedStore) var onlyUsed: Bool = false 30 | 31 | @AppStorage(LinkdingSettingKeys.tagSortOrder.rawValue, store: AppStorageSupport.shared.sharedStore) var sortOrder: SortOrder = .ascending 32 | 33 | @State var selectedItem: TagViewMenuItem? = nil 34 | @State var tagSearchString: String = "" 35 | @State var createSheetOpen: Bool = false 36 | @State var filterSheetOpen: Bool = false 37 | 38 | @State var tagGroupExpanded: Bool = true 39 | 40 | @Binding var openConfig: Bool 41 | 42 | var body: some View { 43 | NavigationSplitView(sidebar: { 44 | if self.syncHadError { 45 | Section() { 46 | SyncErrorView(errorDetails: self.syncErrorMessage) 47 | } 48 | } 49 | List(self.buildMenu(), children: \.children, selection: self.$selectedItem) { item in 50 | if item.children != nil { 51 | HStack { 52 | Image(systemName: "folder") 53 | Text(item.name) 54 | .lineLimit(1) 55 | } 56 | } else { 57 | NavigationLink(value: item, label: { 58 | HStack { 59 | Image(systemName: "tag") 60 | Text(item.name) 61 | .badge(item.badge ?? 0) 62 | .lineLimit(1) 63 | } 64 | }) 65 | } 66 | } 67 | .listStyle(.sidebar) 68 | .navigationTitle("Tags") 69 | .refreshable { 70 | let syncClient = LinkdingSyncClient(tagStore: self.tagStore, bookmarkStore: self.bookmarkStore) 71 | try? await syncClient.sync() 72 | } 73 | .toolbar { 74 | ToolbarItemGroup(placement: .navigationBarLeading) { 75 | ConfigurationButton(actionHandler: { 76 | self.openConfig.toggle() 77 | }) 78 | } 79 | ToolbarItemGroup(placement: .navigationBarTrailing) { 80 | Button(action: { 81 | self.filterSheetOpen = true 82 | }) { 83 | Image(systemName: "slider.horizontal.3") 84 | } 85 | Button(action: { 86 | self.createSheetOpen = true 87 | }) { 88 | Image(systemName: "plus") 89 | } 90 | } 91 | } 92 | .searchable(text: self.$tagSearchString) 93 | }, detail: { 94 | if selectedItem != nil { 95 | if self.selectedItem!.type == .tag { 96 | LinkdingTagBookmarksView(tag: self.selectedItem!.tag, title: self.selectedItem!.name) 97 | } 98 | if self.selectedItem!.type == .untagged { 99 | LinkdingTagBookmarksView(tag: self.selectedItem!.tag, title: self.selectedItem!.name) 100 | } 101 | } else { 102 | Text("Please select a tag") 103 | } 104 | }) 105 | .sheet(isPresented: self.$createSheetOpen) { 106 | CreateTagView() 107 | } 108 | .sheet(isPresented: self.$filterSheetOpen) { 109 | FilterTagView() 110 | } 111 | } 112 | 113 | private func filteredTags() -> [LinkdingTagEntity] { 114 | return self.tagStore 115 | .filteredTags(nameFilter: self.tagSearchString, onlyUsed: self.onlyUsed) 116 | .sorted { 117 | let compareOrder = self.sortOrder == .ascending ? ComparisonResult.orderedAscending : ComparisonResult.orderedDescending 118 | 119 | return $0.name.compare($1.name, options: .caseInsensitive) == compareOrder 120 | } 121 | } 122 | 123 | private func buildMenu() -> [TagViewMenuItem] { 124 | var menu: [TagViewMenuItem] = [] 125 | 126 | menu.append(TagViewMenuItem(name: "Untagged", type: .untagged, badge: self.bookmarkStore.untagged.count)) 127 | 128 | let tags = self.filteredTags().map { 129 | TagViewMenuItem(name: $0.name, type: .tag, badge: $0.bookmarks.count, tag: $0) 130 | } 131 | menu.append(TagViewMenuItem(name: "Tags", type: .folder, children: tags)) 132 | 133 | return menu 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /CompanionApplication/shared/base/BaseIntegrationDashboard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseIntegrationDashboard.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | import SwiftUI 8 | 9 | public protocol BaseIntegrationDashboard { 10 | init(openConfig: Binding) 11 | } 12 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/bookmark/BookmarkListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarkListView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | public protocol FilteredBookmarkStore { 9 | associatedtype ID: Hashable 10 | func filter(text: String) -> [Bookmark] 11 | } 12 | 13 | public struct BookmarkListView: View { 14 | var bookmarkStore: any FilteredBookmarkStore 15 | var showLinkButton: Bool 16 | var showTags: Bool 17 | var showDescription: Bool 18 | var enableDelete: Bool 19 | var tapHandler: (Bookmark) -> Void 20 | var deleteHandler: (Bookmark) -> Void 21 | var preListView: () -> Content 22 | 23 | public init( 24 | bookmarkStore: any FilteredBookmarkStore, 25 | showLinkButton: Bool = true, 26 | showTags: Bool = true, 27 | showDescription: Bool = true, 28 | enableDelete: Bool = true, 29 | tapHandler: @escaping (Bookmark) -> Void = { _ in }, 30 | deleteHandler: @escaping (Bookmark) -> Void = { _ in }, 31 | @ViewBuilder preListView: @escaping () -> Content = { EmptyView() } 32 | ) { 33 | self.bookmarkStore = bookmarkStore 34 | self.showLinkButton = showLinkButton 35 | self.showDescription = showDescription 36 | self.showTags = showTags 37 | self.enableDelete = enableDelete 38 | self.tapHandler = tapHandler 39 | self.deleteHandler = deleteHandler 40 | self.preListView = preListView 41 | } 42 | 43 | @State private var searchText: String = "" 44 | 45 | public var body: some View { 46 | List { 47 | self.preListView() 48 | ForEach(self.filteredBookmarks()) { bookmark in 49 | BookmarkView( 50 | bookmark: bookmark, 51 | showLinkButton: self.showLinkButton, 52 | showTags: self.showTags, 53 | showDescription: self.showDescription, 54 | tapHandler: self.tapHandler 55 | ) 56 | } 57 | .conditionalModifier(self.enableDelete, exec: { 58 | $0.onDelete(perform: self.onDelete) 59 | }) 60 | } 61 | .searchable(text: self.$searchText) 62 | } 63 | 64 | private func filteredBookmarks() -> [Bookmark] { 65 | return self.bookmarkStore.filter(text: self.searchText) 66 | } 67 | 68 | private func onDelete(_ offsets: IndexSet) { 69 | for index in offsets { 70 | let bookmark = self.filteredBookmarks()[index] 71 | self.deleteHandler(bookmark) 72 | } 73 | } 74 | } 75 | 76 | struct BookmarkListView_Previews: PreviewProvider { 77 | struct PreviewBookmarkStore: FilteredBookmarkStore { 78 | func filter(text: String) -> [Bookmark] { 79 | return [ 80 | Bookmark(id: UUID(), title: "bookmark-1", url: "https://www.github.com", tags: [Tag(id: UUID(), name: "tag-1")]), 81 | Bookmark(id: UUID(), title: "bookmark-2", url: "https://www.github.com", tags: []), 82 | Bookmark(id: UUID(), title: "bookmark-3", url: "https://www.github.com", tags: [Tag(id: UUID(), name: "tag-2"), Tag(id: UUID(), name: "tag-3")]), 83 | Bookmark(id: UUID(), title: "bookmark-4", url: "https://www.github.com", description: "A dummy description", tags: []), 84 | Bookmark(id: UUID(), title: "bookmark-5", url: "https://www.github.com", description: "A dummy description", tags: [Tag(id: UUID(), name: "tag-4")]) 85 | ] 86 | } 87 | } 88 | static let store = PreviewBookmarkStore() 89 | 90 | static var previews: some View { 91 | BookmarkListView(bookmarkStore: store) 92 | BookmarkListView(bookmarkStore: store, showLinkButton: false, showTags: false, showDescription: false, enableDelete: false) 93 | BookmarkListView(bookmarkStore: store, preListView: { 94 | Section() { 95 | SyncErrorView(errorDetails: "Some more details about the error.") 96 | } 97 | }) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/bookmark/BookmarkTagsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarkTagsView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct BookmarkTagsView: View { 9 | var tags: [Tag] 10 | 11 | var body: some View { 12 | ScrollView(.horizontal, showsIndicators: false) { 13 | HStack { 14 | ForEach(self.tags) { tag in 15 | HStack { 16 | Text("#\(tag.name)") 17 | .lineLimit(1) 18 | } 19 | .padding(.horizontal, 8) 20 | .padding(.vertical, 4) 21 | .background(.orange) 22 | .cornerRadius(15) 23 | } 24 | } 25 | } 26 | } 27 | } 28 | 29 | struct BookmarkTagsView_Previews: PreviewProvider { 30 | static let tags = [ 31 | Tag(id: UUID(), name: "tag-1"), 32 | Tag(id: UUID(), name: "tag-2"), 33 | Tag(id: UUID(), name: "tag-3"), 34 | ] 35 | 36 | static var previews: some View { 37 | BookmarkTagsView(tags: tags) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/bookmark/BookmarkView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarkView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | public struct BookmarkView: View { 9 | var bookmark: Bookmark 10 | var showLinkButton: Bool 11 | var showTags: Bool 12 | var showDescription: Bool 13 | var tapHandler: (Bookmark) -> Void 14 | 15 | public init( 16 | bookmark: Bookmark, 17 | showLinkButton: Bool = true, 18 | showTags: Bool = true, 19 | showDescription: Bool = true, 20 | tapHandler: @escaping (Bookmark) -> Void = { _ in } 21 | ) { 22 | self.bookmark = bookmark 23 | self.showLinkButton = showLinkButton 24 | self.showTags = showTags 25 | self.showDescription = showDescription 26 | self.tapHandler = tapHandler 27 | } 28 | 29 | public var body: some View { 30 | HStack { 31 | HStack { 32 | VStack(alignment: .leading) { 33 | Text(self.bookmark.title.trimmingCharacters(in: .whitespacesAndNewlines)) 34 | .fontWeight(.bold) 35 | .lineLimit(1) 36 | if self.bookmark.description != nil && self.bookmark.description != "" && self.showDescription == true { 37 | HStack(alignment: .top) { 38 | Text(self.bookmark.description!) 39 | .fontWeight(.light) 40 | } 41 | } 42 | if self.bookmark.tags.count > 0 && self.showTags { 43 | BookmarkTagsView(tags: self.bookmark.tags) 44 | } 45 | } 46 | Spacer() 47 | } 48 | .contentShape(Rectangle()) 49 | .onTapGesture(perform: { 50 | self.tapHandler(self.bookmark) 51 | }) 52 | if self.showLinkButton { 53 | UrlLinkView(url: self.bookmark.url) 54 | .contentShape(Rectangle()) 55 | } 56 | } 57 | } 58 | } 59 | 60 | struct BookmarkView_Previews: PreviewProvider { 61 | static let bookmark = Bookmark(id: UUID(), title: "Dummy Bookmark Title", url: "https://www.github.com", description: "This is a dummy description", tags: [ 62 | Tag(id: UUID(), name: "tag-1"), 63 | Tag(id: UUID(), name: "tag-2") 64 | ]) 65 | 66 | static var previews: some View { 67 | BookmarkView(bookmark: bookmark) 68 | BookmarkView(bookmark: bookmark, showTags: false) 69 | BookmarkView(bookmark: bookmark, showDescription: false) 70 | BookmarkView(bookmark: bookmark, showLinkButton: false) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/bookmark/UrlLinkView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UrlLinkView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | public struct UrlLinkView: View { 9 | @AppStorage(SharedSettingKeys.useInAppBrowser.rawValue, store: AppStorageSupport.shared.sharedStore) var useInAppBrowser: Bool = false 10 | 11 | private var url: String? 12 | 13 | @State private var inAppBrowserOpen: Bool = false 14 | 15 | public init(url: String?) { 16 | self.url = url 17 | } 18 | 19 | public var body: some View { 20 | let url = self.getUrl() 21 | VStack { 22 | if (url != nil) { 23 | if self.useInAppBrowser && !ProcessInfo.processInfo.isiOSAppOnMac { 24 | Button(action: { 25 | self.inAppBrowserOpen = true 26 | }) { 27 | Image(systemName: "link") 28 | } 29 | } else { 30 | Link(destination: url!, label: { 31 | Image(systemName: "link") 32 | }) 33 | } 34 | } else { 35 | Image(systemName: "xmark") 36 | .foregroundColor(.red) 37 | } 38 | } 39 | .padding(.leading, 28) 40 | .fullScreenCover(isPresented: self.$inAppBrowserOpen, content: { 41 | if let urlObj = URL(string: self.url!) { 42 | WebView(url: urlObj) 43 | .edgesIgnoringSafeArea(.all) 44 | } else { 45 | Text("Error: Invalid URL").foregroundColor(.red) 46 | } 47 | }) 48 | } 49 | 50 | private func getUrl() -> URL? { 51 | guard let url = self.url else { 52 | return nil 53 | } 54 | guard let urlString = url.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) else { 55 | return nil 56 | } 57 | return URL(string: urlString) 58 | } 59 | } 60 | 61 | struct UrlLinkView_Previews: PreviewProvider { 62 | static let url = "https://www.github.com" 63 | static let emptyUrl: String? = nil 64 | 65 | static var previews: some View { 66 | UrlLinkView(url: url) 67 | UrlLinkView(url: emptyUrl) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/configuration/ConfigurationButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigurationButton.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | public struct ConfigurationButton: View { 9 | var actionHandler: () -> Void 10 | 11 | public init(actionHandler: @escaping () -> Void) { 12 | self.actionHandler = actionHandler 13 | } 14 | 15 | public var body: some View { 16 | Button(action: { 17 | self.actionHandler() 18 | }, label: { 19 | Image(systemName: "gear") 20 | }) 21 | 22 | } 23 | } 24 | 25 | struct ConfigurationButton_Previews: PreviewProvider { 26 | static var previews: some View { 27 | ConfigurationButton(actionHandler: {}) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/dashboard/Dashboard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dashboard.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct Dashboard: View { 9 | var bookmarkStore: any BookmarkStore 10 | var tagStore: any TagStore 11 | var syncService: SyncService 12 | 13 | var title: String 14 | 15 | @State var openConfig: Bool = false 16 | 17 | var body: some View { 18 | GeometryReader { geometry in 19 | NavigationStack { 20 | ScrollView { 21 | VStack(alignment: .leading) { 22 | HStack { 23 | NavigationLink(destination: BookmarkListViewV2(title: "All bookmarks", bookmarks: self.allBookmarks())) { 24 | DashboardTile(title: "All bookmarks", count: self.allBookmarksCount(), width: geometry.size.width / 2.0) 25 | } 26 | NavigationLink(destination: BookmarkListViewV2(title: "Unread bookmarks", bookmarks: self.unreadBookmarks())) { 27 | DashboardTile(title: "Unread bookmarks", count: self.unreadBookmarksCount(), width: geometry.size.width / 2.0, color: Color.orange, iconName: "tray.full.fill") 28 | } 29 | } 30 | HStack { 31 | VStack { 32 | HStack { 33 | Text("Tags") 34 | .font(.system(size: 24)) 35 | .bold() 36 | Spacer() 37 | } 38 | ForEach(self.tagStore.filter(text: nil)) { tag in 39 | NavigationLink(destination: BookmarkListViewV2(title: tag.name, bookmarks: self.bookmarkStore.byTag(tag: tag))) { 40 | DashboardTagListItem(tagName: tag.name, tagBookmarkCount: self.tagBookmarkCount(tag: tag), width: geometry.size.width) 41 | } 42 | } 43 | } 44 | } 45 | .padding(10) 46 | } 47 | } 48 | .navigationTitle(self.title) 49 | .toolbar { 50 | ToolbarItem(placement: .navigationBarLeading) { 51 | ConfigurationButton(actionHandler: { 52 | self.openConfig = true 53 | }) 54 | } 55 | } 56 | .sheet(isPresented: self.$openConfig) { 57 | ConfigurationSheet() 58 | } 59 | .refreshable { 60 | await self.syncService.runFullSync() 61 | } 62 | } 63 | } 64 | } 65 | 66 | func allBookmarks() -> [Bookmark] { 67 | return self.bookmarkStore.filter(text: nil, filter: .all) 68 | } 69 | 70 | func allBookmarksCount() -> Int { 71 | return self.allBookmarks().count 72 | } 73 | 74 | func unreadBookmarks() -> [Bookmark] { 75 | return self.bookmarkStore.filter(text: nil, filter: .unread) 76 | } 77 | 78 | func unreadBookmarksCount() -> Int { 79 | return self.unreadBookmarks().count 80 | } 81 | 82 | func tagBookmarkCount(tag: Tag) -> Int { 83 | return self.bookmarkStore 84 | .byTag(tag: tag) 85 | .count 86 | } 87 | } 88 | 89 | struct Dashboard_Previews: PreviewProvider { 90 | struct PreviewBookmarkStore: BookmarkStore { 91 | typealias ID = UUID 92 | 93 | func filter(text: String?, filter: BookmarkStoreFilter?) -> [Bookmark] { 94 | if filter == .all { 95 | return [ 96 | Bookmark(id: UUID(), title: "Dummy Title 1", url: "http://dummy-url-1", tags: []), 97 | Bookmark(id: UUID(), title: "Dummy Title 2", url: "http://dummy-url-2", tags: []) 98 | ] 99 | } else { 100 | return [ 101 | Bookmark(id: UUID(), title: "Dummy Title 3", url: "http://dummy-url-3", tags: []) 102 | ] 103 | } 104 | } 105 | 106 | func byTag(tag: Tag) -> [Bookmark] { 107 | return [] 108 | } 109 | } 110 | 111 | struct PreviewTagStore: TagStore { 112 | typealias ID = UUID 113 | 114 | func filter(text: String?) -> [Tag] { 115 | return [ 116 | Tag(id: UUID(), name: "tag-1"), 117 | Tag(id: UUID(), name: "tag-2"), 118 | Tag(id: UUID(), name: "tag-3") 119 | ] 120 | } 121 | } 122 | 123 | struct PreviewSyncService: SyncService { 124 | func runFullSync() {} 125 | } 126 | 127 | static var previews: some View { 128 | Dashboard(bookmarkStore: PreviewBookmarkStore(), tagStore: PreviewTagStore(), syncService: PreviewSyncService(), title: "Preview") 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/dashboard/DashboardTagListItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DashboardTagListItem.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct DashboardTagListItem: View { 9 | var tagName: String = "" 10 | var tagBookmarkCount: Int = 0 11 | var width: CGFloat = .infinity 12 | 13 | private let backgroundColor: Color = Color.green 14 | private let countBackgroundColor: Color = Color.gray.opacity(0.8) 15 | 16 | var body: some View { 17 | HStack { 18 | HStack { 19 | Image(systemName: "tag") 20 | Text(self.tagName) 21 | } 22 | Spacer() 23 | ZStack { 24 | Text("\(self.tagBookmarkCount)") 25 | .padding(5) 26 | } 27 | .background(self.countBackgroundColor) 28 | .cornerRadius(15) 29 | } 30 | .padding(6) 31 | .frame(width: self.width - (2*10)) 32 | .background(self.backgroundColor) 33 | .cornerRadius(10) 34 | } 35 | } 36 | 37 | struct DashboardTagListItem_Previews: PreviewProvider { 38 | static var previews: some View { 39 | VStack(alignment: .center) { 40 | DashboardTagListItem(tagName: "dummy-tag-1") 41 | DashboardTagListItem(tagName: "dummy-tag-2", tagBookmarkCount: 10) 42 | DashboardTagListItem(tagName: "dummy-tag-2", tagBookmarkCount: 10000000) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/dashboard/DashboardTile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DashboardTile.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct DashboardTile: View { 9 | var title: String = "" 10 | var count: Int = 0 11 | var width: CGFloat = CGFloat.infinity 12 | var color: Color = Color.red 13 | var iconName: String = "tray" 14 | 15 | private let outerPadding: CGFloat = 5.0 16 | private let innerPadding: CGFloat = 10.0 17 | 18 | var body: some View { 19 | ZStack { 20 | VStack(alignment: .leading) { 21 | HStack { 22 | Image(systemName: self.iconName) 23 | .bold() 24 | Spacer() 25 | Text("\(self.count)") 26 | .font(.headline) 27 | 28 | } 29 | .padding(EdgeInsets(top: 0, leading: 0, bottom: 2, trailing: 0)) 30 | HStack { 31 | Text(self.title) 32 | } 33 | } 34 | .padding(self.innerPadding) 35 | } 36 | .frame(width: self.calculateWidth()) 37 | .background(self.color) 38 | .cornerRadius(10) 39 | .padding(self.outerPadding) 40 | } 41 | 42 | private func calculateWidth() -> CGFloat { 43 | return self.width - self.innerPadding - self.outerPadding 44 | } 45 | } 46 | 47 | struct DashboardTile_Previews: PreviewProvider { 48 | static var previews: some View { 49 | GeometryReader { geometry in 50 | HStack { 51 | DashboardTile(title: "All Bookmarks", count: 100, width: geometry.size.width / 2.0, color: Color.red) 52 | DashboardTile(title: "Unread Bookmarks", count: 42, width: geometry.size.width / 2.0, color: Color.blue, iconName: "tray.full.fill") 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/dashboard/bookmark/BookmarkListItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarkListItemView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct BookmarkListItemView: View { 9 | @AppStorage(SharedSettingKeys.showDescription.rawValue, store: AppStorageSupport.shared.sharedStore) var showDescription: Bool = false 10 | 11 | var bookmark: Bookmark 12 | 13 | var body: some View { 14 | VStack(alignment: .leading) { 15 | Text(self.bookmark.title.trimmingCharacters(in: .whitespacesAndNewlines)) 16 | .fontWeight(.bold) 17 | .lineLimit(1) 18 | if self.bookmark.description != nil && self.bookmark.description != "" && self.showDescription == true { 19 | HStack(alignment: .top) { 20 | Text(self.bookmark.description!) 21 | .fontWeight(.light) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | 28 | #Preview { 29 | BookmarkListItemView( 30 | bookmark: Bookmark(id: UUID(), title: "Dummy-Title", url: "https://dummy", tags: []) 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/dashboard/bookmark/BookmarkListViewV2.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarkListView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct BookmarkListViewV2: View { 9 | var title: String 10 | var bookmarks: [Bookmark] 11 | 12 | var body: some View { 13 | Form { 14 | ForEach(self.bookmarks) { bookmark in 15 | HStack(alignment: .top) { 16 | BookmarkListItemView(bookmark: bookmark) 17 | Spacer() 18 | UrlLinkView(url: bookmark.url) 19 | } 20 | } 21 | } 22 | .navigationTitle(self.title) 23 | } 24 | } 25 | 26 | #Preview { 27 | BookmarkListViewV2( 28 | title: "Dummy Tag", 29 | bookmarks: [ 30 | Bookmark(id: UUID(), title: "Dummy Title 1", url: "http://dummy-url-1", tags: []), 31 | Bookmark(id: UUID(), title: "Dummy Title 2", url: "http://dummy-url-2", tags: []) 32 | ] 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/dashboard/configuration/ConfigurationSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigurationSheet.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct ConfigurationSheet: View { 9 | var body: some View { 10 | NavigationView { 11 | ConfigurationView( 12 | dismissToolbarItem: { 13 | Text("Close") 14 | }, dismissHandler: { 15 | return true 16 | } 17 | ) 18 | .navigationTitle("Configuration") 19 | } 20 | } 21 | } 22 | 23 | struct ConfigurationSheet_Previews: PreviewProvider { 24 | static var previews: some View { 25 | ConfigurationSheet() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/error/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | public struct ErrorView: View { 9 | private var title: String 10 | private var message: String 11 | private var details: String? 12 | 13 | public init(title: String, message: String, details: String? = nil) { 14 | self.title = title 15 | self.message = message 16 | self.details = details 17 | } 18 | 19 | public var body: some View { 20 | VStack { 21 | Section() { 22 | VStack { 23 | Text(self.title) 24 | .foregroundColor(.red) 25 | .bold() 26 | VStack(alignment: .leading) { 27 | Text(self.message) 28 | .textSelection(.enabled) 29 | if self.details != nil { 30 | Text(self.details!) 31 | .fontWeight(.light) 32 | .textSelection(.enabled) 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | 41 | struct ErrorView_Previews: PreviewProvider { 42 | static var previews: some View { 43 | Form { 44 | ErrorView(title: "Error Title", message: "Error Message with some more information that can be a bit detailed.") 45 | } 46 | Form { 47 | ErrorView(title: "Error Title", message: "Error Message with some more information that can be a bit detailed.", details: "Some very detailed information about the error. That could possibility be a very long output from a backend request.") 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/error/SyncErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SyncErrorView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | public struct SyncErrorView: View { 9 | var errorDetails: String? 10 | 11 | public init(errorDetails: String? = nil) { 12 | self.errorDetails = errorDetails 13 | } 14 | 15 | public var body: some View { 16 | VStack { 17 | Section() { 18 | ErrorView( 19 | title: "Synchronization error.", 20 | message: "Please check your URL and your Token in the configuration dialog.", 21 | details: self.errorDetails 22 | ) 23 | } 24 | } 25 | } 26 | } 27 | 28 | struct SyncErrorView_Previews: PreviewProvider { 29 | static var previews: some View { 30 | Form { 31 | SyncErrorView() 32 | } 33 | Form { 34 | SyncErrorView(errorDetails: "Dummy Error Message") 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/list/CommonSelectListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommonListView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | public protocol CreateNotFoundItemHandler { 9 | func createItem(text: String) 10 | } 11 | 12 | public protocol CommonListItem: Hashable, Identifiable { 13 | func getDisplayText() -> String 14 | } 15 | 16 | public struct CommonSelectListView: View { 17 | private var createNotFoundHandler: CreateNotFoundItemHandler? 18 | private var items: [T] 19 | private var selectedItems: Binding> 20 | 21 | @State private var searchTerm: String = "" 22 | 23 | public init( 24 | items: [T], 25 | selectedItems: Binding>, 26 | createNotFoundHandler: CreateNotFoundItemHandler? = nil 27 | ) { 28 | self.items = items 29 | self.createNotFoundHandler = createNotFoundHandler 30 | self.selectedItems = selectedItems 31 | } 32 | 33 | public var body: some View { 34 | List { 35 | let exactSearchMatch = !self.filteredItems().map({ $0.getDisplayText().lowercased() }).contains(self.searchTerm.lowercased()) 36 | let showCreateButton = (exactSearchMatch && self.searchTerm != "") && self.createNotFoundHandler != nil 37 | 38 | if self.filteredItems().isEmpty && !showCreateButton { 39 | Text("No items") 40 | } else { 41 | ForEach(self.filteredItems()) { item in 42 | SelectableListItemView(text: item.getDisplayText(), selected: self.selectedItems.wrappedValue.contains(item), tapHandler: { 43 | if self.selectedItems.wrappedValue.contains(item) { 44 | self.selectedItems.wrappedValue.remove(item) 45 | } else { 46 | self.selectedItems.wrappedValue.insert(item) 47 | } 48 | }) 49 | } 50 | } 51 | 52 | if showCreateButton { 53 | Button(action: { 54 | self.createNotFoundHandler?.createItem(text: self.searchTerm) 55 | }) { 56 | HStack { 57 | Image(systemName: "square.and.pencil") 58 | .foregroundColor(.blue) 59 | Text("Create \(self.searchTerm)") 60 | .foregroundColor(.blue) 61 | } 62 | } 63 | .buttonStyle(.plain) 64 | } 65 | } 66 | .searchable(text: self.$searchTerm) 67 | } 68 | 69 | func filteredItems() -> [T] { 70 | if self.searchTerm == "" { 71 | return self.items 72 | } 73 | 74 | let searchTermLower = self.searchTerm.lowercased() 75 | 76 | return self.items 77 | .filter { 78 | return $0.getDisplayText() 79 | .lowercased() 80 | .contains(searchTermLower) 81 | } 82 | } 83 | } 84 | 85 | struct CommonListView_Previews: PreviewProvider { 86 | struct TestItem: CommonListItem { 87 | var id: UUID 88 | var text: String 89 | func getDisplayText() -> String { 90 | return self.text 91 | } 92 | } 93 | 94 | struct TestHandler: CreateNotFoundItemHandler { 95 | func createItem(text: String) { 96 | } 97 | } 98 | 99 | static var testItems = [ 100 | TestItem(id: UUID(), text: "test-1"), 101 | TestItem(id: UUID(), text: "test-2"), 102 | TestItem(id: UUID(), text: "test-3") 103 | ] 104 | 105 | @State static var selection: Set = [testItems[0]] 106 | 107 | static var previews: some View { 108 | NavigationView { 109 | CommonSelectListView(items: testItems, selectedItems: $selection) 110 | }.previewDisplayName("without create handler") 111 | NavigationView { 112 | CommonSelectListView(items: testItems, selectedItems: $selection, createNotFoundHandler: TestHandler()) 113 | }.previewDisplayName("with create handler") 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/list/SelectableListItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelectableListItemView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct SelectableListItemView: View { 9 | var text: String 10 | var selected: Bool 11 | var tapHandler: () -> Void 12 | 13 | var body: some View { 14 | HStack { 15 | Text(text) 16 | Spacer() 17 | if self.selected { 18 | Image(systemName: "checkmark") 19 | } 20 | } 21 | .contentShape(Rectangle()) 22 | .onTapGesture { 23 | self.tapHandler() 24 | } 25 | } 26 | } 27 | 28 | struct SelectableListItemView_Previews: PreviewProvider { 29 | @State static var item1Selected: Bool = true 30 | @State static var item2Selected: Bool = false 31 | 32 | static var previews: some View { 33 | List { 34 | SelectableListItemView(text: "item-1", selected: item1Selected, tapHandler: { 35 | item1Selected.toggle() 36 | }) 37 | SelectableListItemView(text: "item-2", selected: item2Selected, tapHandler: { 38 | item2Selected.toggle() 39 | }) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/tag/TagListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagListView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | public protocol FilteredTagStore { 9 | associatedtype ID: Hashable 10 | func filter(text: String) -> [Tag] 11 | } 12 | 13 | public struct TagListView: View { 14 | var tagStore: any FilteredTagStore 15 | 16 | public init(tagStore: any FilteredTagStore) { 17 | self.tagStore = tagStore 18 | } 19 | 20 | @State private var searchText: String = "" 21 | 22 | public var body: some View { 23 | List { 24 | ForEach(self.filteredTags()) { tag in 25 | TagView(tag: tag) 26 | } 27 | } 28 | .searchable(text: self.$searchText) 29 | } 30 | 31 | private func filteredTags() -> [Tag] { 32 | return self.tagStore.filter(text: self.searchText) 33 | } 34 | } 35 | 36 | struct TagListView_Previews: PreviewProvider { 37 | struct PreviewTagStore: FilteredTagStore { 38 | func filter(text: String) -> [Tag] { 39 | return [ 40 | Tag(id: UUID(), name: "tag-1"), 41 | Tag(id: UUID(), name: "tag-2"), 42 | Tag(id: UUID(), name: "tag-3"), 43 | Tag(id: UUID(), name: "tag-4") 44 | ] 45 | } 46 | } 47 | static let store = PreviewTagStore() 48 | 49 | static var previews: some View { 50 | TagListView(tagStore: store) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/tag/TagView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct TagView: View { 9 | var tag: Tag 10 | 11 | var body: some View { 12 | Text(self.tag.name) 13 | } 14 | } 15 | 16 | struct TagView_Previews: PreviewProvider { 17 | static var previews: some View { 18 | TagView(tag: Tag(id: UUID(), name: "tag-1")) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CompanionApplication/shared/components/webview/WebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | import SafariServices 8 | 9 | struct WebView: UIViewControllerRepresentable { 10 | typealias UIViewControllerType = SFSafariViewController 11 | 12 | let url: URL 13 | 14 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> SFSafariViewController { 15 | return SFSafariViewController(url: self.url) 16 | } 17 | 18 | func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext) { 19 | } 20 | } 21 | 22 | struct WebView_Previews: PreviewProvider { 23 | static var previews: some View { 24 | WebView(url: URL(string: "https://www.google.de")!) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /CompanionApplication/shared/config/GlobalSettingKeys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GlobalSettingKeys.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public enum GlobalSettingKeys: String { 9 | case integrationSelected = "global.integration.selected" 10 | } 11 | -------------------------------------------------------------------------------- /CompanionApplication/shared/config/Integrations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Integrations.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public enum Integrations: String { 9 | case linkding = "linkding" 10 | } 11 | -------------------------------------------------------------------------------- /CompanionApplication/shared/extensions/View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewExtensions.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | import SwiftUI 8 | 9 | extension View { 10 | @ViewBuilder func `conditionalModifier`(_ condition: Bool, exec: (Self) -> T) -> some View where T: View { 11 | if condition { 12 | exec(self) 13 | } else { 14 | self 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /CompanionApplication/shared/model/Bookmark.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bookmark.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public struct Bookmark: Identifiable { 9 | public var id: ID 10 | public var title: String 11 | public var url: String 12 | public var description: String? 13 | public var tags: [Tag] 14 | 15 | public init(id: ID, title: String, url: String, description: String? = nil, tags: [Tag]) { 16 | self.id = id 17 | self.title = title 18 | self.url = url 19 | self.description = description 20 | self.tags = tags 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CompanionApplication/shared/model/BookmarkStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarkStore.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public enum BookmarkStoreFilter { 9 | case all 10 | case unread 11 | } 12 | 13 | public protocol BookmarkStore { 14 | associatedtype ID: Hashable 15 | func filter(text: String?, filter: BookmarkStoreFilter?) -> [Bookmark] 16 | func byTag(tag: Tag) -> [Bookmark] 17 | } 18 | 19 | -------------------------------------------------------------------------------- /CompanionApplication/shared/model/Tag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tag.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public struct Tag: Identifiable { 9 | public var id: ID 10 | public var name: String 11 | 12 | public init(id: ID, name: String) { 13 | self.id = id 14 | self.name = name 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /CompanionApplication/shared/model/TagStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagStore.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public protocol TagStore { 9 | associatedtype ID: Hashable 10 | func filter(text: String?) -> [Tag] 11 | } 12 | -------------------------------------------------------------------------------- /CompanionApplication/shared/settings/AppStorageSupport.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppStorageSupport.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public struct AppStorageSupport { 9 | public static let shared: AppStorageSupport = AppStorageSupport() 10 | 11 | private(set) public var sharedStore: UserDefaults 12 | 13 | public init() { 14 | self.sharedStore = UserDefaults(suiteName: "group.bookmarkcompanion")! 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /CompanionApplication/shared/settings/SecureSettingsError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecureSettingsError.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public class SecureSettingsError: Error {} 9 | -------------------------------------------------------------------------------- /CompanionApplication/shared/settings/SecureSettingsSupport.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecureSettingsSupport.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public class SecureSettingsSupport { 9 | public static func setSecureSettingString(key: String, value: String) throws { 10 | guard let data = value.data(using: .utf8) else { 11 | throw SecureSettingsError() 12 | } 13 | let keychainItemQuery = [ 14 | kSecAttrAccount: key, 15 | kSecValueData: data, 16 | kSecClass: kSecClassGenericPassword, 17 | kSecAttrAccessGroup: "group.bookmarkcompanion" 18 | ] as CFDictionary 19 | SecItemDelete(keychainItemQuery) 20 | SecItemAdd(keychainItemQuery, nil) 21 | } 22 | 23 | public static func getSecureSettingString(key: String) throws -> String { 24 | let keychainItemQuery = [ 25 | kSecAttrAccount: key, 26 | kSecClass: kSecClassGenericPassword, 27 | kSecReturnAttributes: true, 28 | kSecReturnData: true, 29 | kSecMatchLimit: 1, 30 | kSecAttrAccessGroup: "group.bookmarkcompanion" 31 | ] as CFDictionary 32 | var result: AnyObject? 33 | SecItemCopyMatching(keychainItemQuery, &result) 34 | guard let data = result else { 35 | throw SecureSettingsError() 36 | } 37 | let resultData = data[kSecValueData] as! Data 38 | guard let strValue = String(data: resultData, encoding: .utf8) else { 39 | throw SecureSettingsError() 40 | } 41 | return strValue 42 | } 43 | 44 | public static func deleteSecureSetting(key: String) { 45 | let keychainItemQuery = [ 46 | kSecAttrAccount: key, 47 | kSecClass: kSecClassGenericPassword, 48 | kSecAttrAccessGroup: "group.bookmarkcompanion" 49 | ] as CFDictionary 50 | SecItemDelete(keychainItemQuery) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /CompanionApplication/shared/settings/SharedSettingKeys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SharedSettingKeys.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public enum SharedSettingKeys: String { 9 | case useInAppBrowser = "shared.browser.inapp" 10 | case useExperimentalDashboard = "shared.experimental.dashboard" 11 | case showDescription = "shared.list.showdescription" 12 | } 13 | -------------------------------------------------------------------------------- /CompanionApplication/shared/sync/BackendSupportClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackendSupportClient.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public protocol BackendSupportClientProtocol { 9 | func isBackendAvailable() async -> Bool 10 | } 11 | -------------------------------------------------------------------------------- /CompanionApplication/shared/sync/BackendSyncWorker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackendSyncWorker.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | public protocol BackendSyncWorkerPackage { 9 | func run() 10 | } 11 | 12 | public class BackendSyncWorker { 13 | func queueWorkPackage(package: BackendSyncWorkerPackage) { 14 | // TODO: Implement background sync 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /CompanionApplication/shared/sync/SyncService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SyncService.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import Foundation 7 | 8 | protocol SyncService { 9 | func runFullSync() async -> Void 10 | } 11 | -------------------------------------------------------------------------------- /CompanionApplicationTests/ApplicationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApplicationTests.swift 3 | // ApplicationTests 4 | // 5 | // Created by Christian Wilhelm on 05.05.23. 6 | // 7 | 8 | import XCTest 9 | @testable import CompanionApplication 10 | 11 | final class ApplicationTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | // Any test you write for XCTest can be annotated as throws and async. 25 | // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. 26 | // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. 27 | } 28 | 29 | func testPerformanceExample() throws { 30 | // This is an example of a performance test case. 31 | self.measure { 32 | // Put the code you want to measure the time of here. 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /CompanionApplicationTests/integrations/linkding/persistence/migrations/CoreDataMigration1To2Test.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataMigration1To2Test.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import XCTest 7 | @testable import CompanionApplication 8 | 9 | import CoreData 10 | 11 | final class CoreDataMigration1To2Test: XCTestCase { 12 | private func cleanStore(context: NSManagedObjectContext, fileUrl: URL) throws { 13 | for store in context.persistentStoreCoordinator!.persistentStores { 14 | try context.persistentStoreCoordinator!.remove(store) 15 | } 16 | 17 | try? FileManager.default.removeItem(at: fileUrl) 18 | } 19 | 20 | private var dataDirectory: URL { 21 | get { 22 | return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! 23 | } 24 | } 25 | 26 | private func loadManagedObjectModel(name: String) -> NSManagedObjectModel { 27 | let sourceModelUrl = Bundle(for: BookmarkCompanionPersistentContainer.self) 28 | .url(forResource: "LinkdingModel", withExtension: "momd")! 29 | .appendingPathComponent(name) 30 | .appendingPathExtension("mom") 31 | return NSManagedObjectModel(contentsOf: sourceModelUrl)! 32 | } 33 | 34 | private func loadObjectContext(sqliteUrl: URL, mom: NSManagedObjectModel) throws -> NSManagedObjectContext { 35 | let coordinator = NSPersistentStoreCoordinator(managedObjectModel: mom) 36 | try coordinator.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: sqliteUrl) 37 | let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) 38 | context.persistentStoreCoordinator = coordinator 39 | return context 40 | } 41 | 42 | func testMigration() throws { 43 | let sourceSqliteUrl = self.dataDirectory.appendingPathComponent("SourceLinkdingModel.sqlite") 44 | let targetSqliteUrl = self.dataDirectory.appendingPathComponent("TargetLinkdingModel.sqlite") 45 | 46 | let sourceMom = self.loadManagedObjectModel(name: "LinkdingModel") 47 | let sourceContext = try self.loadObjectContext(sqliteUrl: sourceSqliteUrl, mom: sourceMom) 48 | 49 | XCTAssertFalse(NSEntityDescription.entity(forEntityName: "Bookmark", in: sourceContext)!.propertiesByName.keys.contains("internalId")) 50 | XCTAssertFalse(NSEntityDescription.entity(forEntityName: "Tag", in: sourceContext)!.propertiesByName.keys.contains("internalId")) 51 | 52 | let sourceBookmark = NSEntityDescription.insertNewObject(forEntityName: "Bookmark", into: sourceContext) 53 | sourceBookmark.setValue(100, forKey: "serverId") 54 | sourceBookmark.setValue("https://www.github.com", forKey: "url") 55 | sourceBookmark.setValue("GitHub", forKey: "title") 56 | sourceBookmark.setValue("", forKey: "urlDescription") 57 | sourceBookmark.setValue("", forKey: "websiteTitle") 58 | sourceBookmark.setValue("", forKey: "websiteDescription") 59 | sourceBookmark.setValue(false, forKey: "isArchived") 60 | sourceBookmark.setValue(false, forKey: "unread") 61 | sourceBookmark.setValue(false, forKey: "shared") 62 | sourceBookmark.setValue(false, forKey: "locallyDeleted") 63 | sourceBookmark.setValue(false, forKey: "locallyModified") 64 | sourceBookmark.setValue(NSSet(), forKey: "relTags") 65 | let sourceTag = NSEntityDescription.insertNewObject(forEntityName: "Tag", into: sourceContext) 66 | sourceTag.setValue(200, forKey: "serverId") 67 | sourceTag.setValue("dummy-tag", forKey: "name") 68 | sourceTag.setValue(NSSet(), forKey: "relBookmarks") 69 | try sourceContext.save() 70 | 71 | let targetMom = self.loadManagedObjectModel(name: "LinkdingModel 2") 72 | let mapping = NSMappingModel(from: nil, forSourceModel: sourceMom, destinationModel: targetMom) 73 | let manager = NSMigrationManager(sourceModel: sourceMom, destinationModel: targetMom) 74 | 75 | try manager.migrateStore( 76 | from: sourceSqliteUrl, 77 | sourceType: NSSQLiteStoreType, 78 | options: nil, 79 | with: mapping, 80 | toDestinationURL: targetSqliteUrl, 81 | destinationType: NSSQLiteStoreType, 82 | destinationOptions: nil 83 | ) 84 | 85 | let targetContext = try self.loadObjectContext(sqliteUrl: targetSqliteUrl, mom: targetMom) 86 | 87 | XCTAssertTrue(NSEntityDescription.entity(forEntityName: "Bookmark", in: targetContext)!.propertiesByName.keys.contains("internalId")) 88 | XCTAssertTrue(NSEntityDescription.entity(forEntityName: "Tag", in: targetContext)!.propertiesByName.keys.contains("internalId")) 89 | 90 | let bookmarks = try targetContext.fetch(NSFetchRequest(entityName: "Bookmark")) 91 | XCTAssertEqual(bookmarks.count, 1) 92 | let testBookmark = bookmarks.first! as! NSManagedObject 93 | XCTAssertNotNil(testBookmark.value(forKey: "internalId")) 94 | XCTAssertEqual(testBookmark.value(forKey: "serverId") as! Int, 100) 95 | XCTAssertEqual(testBookmark.value(forKey: "url") as! String, "https://www.github.com") 96 | XCTAssertEqual(testBookmark.value(forKey: "title") as! String, "GitHub") 97 | 98 | let tags = try targetContext.fetch(NSFetchRequest(entityName: "Tag")) 99 | XCTAssertEqual(tags.count, 1) 100 | let testTag = tags.first! as! NSManagedObject 101 | XCTAssertNotNil(testTag.value(forKey: "internalId")) 102 | XCTAssertEqual(testTag.value(forKey: "serverId") as! Int, 200) 103 | XCTAssertEqual(testTag.value(forKey: "name") as! String, "dummy-tag") 104 | 105 | try? self.cleanStore(context: sourceContext, fileUrl: sourceSqliteUrl) 106 | try? self.cleanStore(context: targetContext, fileUrl: targetSqliteUrl) 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /Docs/Images/screenshot_ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/Docs/Images/screenshot_ipad.png -------------------------------------------------------------------------------- /Docs/Images/screenshot_iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acez/bookmark-companion/efec801ac320c150f2153535daf93647f0dcfc30/Docs/Images/screenshot_iphone.png -------------------------------------------------------------------------------- /Docs/Release.md: -------------------------------------------------------------------------------- 1 | # Bookmark Companion Releases 2 | 3 | ## Version 4 | 5 | * Version schema: \<**major**\>.\<**minor**\>.\<**patch**\> 6 | * **Major** version to be increased when new integrations are added 7 | * **Minor** version to be increated when new features and big improvements are added 8 | * **Patch** version for small improvements and bugfixes 9 | 10 | ## AppStore Screenshots 11 | 12 | ### Environment 13 | 14 | * Tags 15 | * apple 16 | * awesome 17 | * development 18 | * django 19 | * framework 20 | * linkding 21 | * opensource 22 | * python 23 | * selfhosted 24 | * web 25 | * Create Bookmarks 26 | * https://www.python.org/ 27 | * Tags: python, awesome, development 28 | * https://www.djangoproject.com/ 29 | * Tags: django, framework, python, web 30 | * https://www.apple.com/ 31 | * Tags: apple 32 | * https://github.com/sissbruecker/linkding 33 | * Tags: linkding, opensource, selfhosted 34 | 35 | ### Simulators to be used 36 | 37 | * iPhone 6.5" -> Iphone 11 Pro Max 38 | * iPhone 5.5" -> Iphone 8 Plus 39 | * iPad 12.9" (6th Gen) -> iPad Pro 12.9" (6th generation) 40 | * iPad 12.9" (2nd Gen) -> iPad Pro 12.9" (2nd generation) 41 | 42 | ### Views 43 | 44 | * Bookmark list view 45 | * Create bookmark 46 | * Edit bookmark: Python.org 47 | * Bookmark list view settings 48 | * Tag list (for Ipads: Select apple tag) 49 | * Create tag 50 | -------------------------------------------------------------------------------- /Docs/Structure.md: -------------------------------------------------------------------------------- 1 | # Structure 2 | 3 | ## Project Structure 4 | 5 | * Main application 6 | * The main application code is located in the `BookmarkCompanion` project. This is the main app that is bundled, signed and shipped to the AppStore. 7 | * Share extension 8 | * The extension for sharing URLs with the application from the iOS Share Sheet 9 | * CompanionApplication Framework 10 | * All shared code (Helpers, Components, ...) are located in the `Shared` framework. 11 | * Integrations 12 | * All integrations are placed within the integration folder with all their code (like dtos, sync client, UI parts, coredata model) 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Christian Wilhelm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bookmark Companion 2 | 3 | ## Description 4 | 5 | Bookmark Companion is your little helper to manage bookmarks from your self-hosted service. 6 | You can add, modify and search your bookmarks while you are on the go. Create new bookmarks conveniently via the Share extension. 7 | 8 | ### Supported services 9 | 10 | - Linkding 11 | 12 | ### Key features 13 | 14 | - Create and edit bookmarks & tags 15 | - Search for bookmarks & tags 16 | - Sort and filter lists 17 | - Configurable list view 18 | - Offline functionality 19 | - Share URLs from Bookmark Companion to other apps 20 | - Share sheet to add new bookmarks from other apps 21 | - Open links in an integrated browser 22 | 23 | ## Screenshots 24 | 25 | 26 | 27 | ## Build Project 28 | 29 | Create a new file *Config.xcconfig* in the project root. 30 | ``` 31 | // Configuration settings file format documentation can be found at: 32 | // https://help.apple.com/xcode/#/dev745c5c974 33 | DEVELOPMENT_TEAM = 34 | ``` 35 | After that you can just compile & run the project with XCode. 36 | 37 | ## Documentation 38 | 39 | * [New App Releases](Docs/Release.md) 40 | * [App Structure](Docs/Structure.md) 41 | 42 | ## AppStore 43 | 44 | You can support development of the project by using the Version available on the AppStore. 45 | 46 | [![AppStore](AppStore/appstore-download.svg)](https://apps.apple.com/us/app/bookmarkcompanion/id6444032742) 47 | 48 | ## License 49 | 50 | * [MIT License](LICENSE.md) 51 | -------------------------------------------------------------------------------- /ShareExtension/ActivationRule.md: -------------------------------------------------------------------------------- 1 | SUBQUERY ( 2 | extensionItems, 3 | $extensionItem, 4 | SUBQUERY ( 5 | $extensionItem.attachments, 6 | $attachment, 7 | ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" 8 | ).@count == 1 9 | ).@count == 1 10 | -------------------------------------------------------------------------------- /ShareExtension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionAttributes 8 | 9 | NSExtensionActivationRule 10 | SUBQUERY (extensionItems, $extensionItem, SUBQUERY ($extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url").@count == 1).@count == 1 11 | 12 | NSExtensionPointIdentifier 13 | com.apple.share-services 14 | NSExtensionPrincipalClass 15 | $(PRODUCT_MODULE_NAME).ShareViewController 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /ShareExtension/ShareBookmarkContainerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareBookmarkContainerView.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | import CompanionApplication 8 | 9 | struct ShareBookmarkContainerView: View { 10 | @AppStorage(LinkdingSettingKeys.configComplete.rawValue, store: AppStorageSupport.shared.sharedStore) var configComplete: Bool = false 11 | 12 | var onClose: @MainActor () -> () 13 | var url: String 14 | 15 | var body: some View { 16 | if (self.configComplete) { 17 | ShareBookmarkCreate(url: url, onClose: onClose) 18 | } else { 19 | Text("Please setup BookmarkCompanion first.") 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ShareExtension/ShareBookmarkCreate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareBookmarkCreate.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | import CompanionApplication 8 | 9 | struct ShareBookmarkCreate: View { 10 | var bookmarkStore: LinkdingBookmarkStore = LinkdingBookmarkStore() 11 | var tagStore: LinkdingTagStore = LinkdingTagStore() 12 | 13 | @AppStorage(LinkdingSettingKeys.createBookmarkDefaultArchived.rawValue, store: AppStorageSupport.shared.sharedStore) var defaultArchived: Bool = false 14 | @AppStorage(LinkdingSettingKeys.createBookmarkDefaultUnread.rawValue, store: AppStorageSupport.shared.sharedStore) var defaultUnread: Bool = false 15 | @AppStorage(LinkdingSettingKeys.createBookmarkDefaultShared.rawValue, store: AppStorageSupport.shared.sharedStore) var defaultShared: Bool = false 16 | 17 | @State var url: String = "" 18 | @State var title: String = "" 19 | @State var displayTitle: String = "" 20 | @State var description: String = "" 21 | @State var isArchived: Bool = false 22 | @State var unread: Bool = false 23 | @State var shared: Bool = false 24 | @State var tags = Set() 25 | @State var bookmark: LinkdingBookmarkEntity? 26 | 27 | @State var selectTagsOpen: Bool = false 28 | @State var linkdingAvailable: Bool = false 29 | @State var requestInProgress: Bool = false 30 | 31 | var onClose: @MainActor () -> () 32 | 33 | var body: some View { 34 | NavigationView { 35 | Form { 36 | Section( 37 | content: { 38 | TextField(text: $url) { 39 | Text("URL") 40 | } 41 | TextField(text: $title) { 42 | Text(self.bookmark?.websiteTitle ?? "Title") 43 | } 44 | TextField(text: $description) { 45 | Text(self.bookmark?.websiteDescription ?? "Description") 46 | } 47 | }, 48 | header: { 49 | Text( "Bookmark") 50 | }, 51 | footer: { 52 | if !self.linkdingAvailable { 53 | Text("Linkding backend is not available. Bookmark is stored on your device.") 54 | .font(.caption) 55 | .foregroundColor(.red) 56 | } 57 | } 58 | ) 59 | Section("Flags") { 60 | Toggle(isOn: $unread) { 61 | Text("Unread") 62 | } 63 | Toggle(isOn: $shared) { 64 | Text("Shared") 65 | } 66 | } 67 | Section(content: { 68 | if (self.tags.count > 0) { 69 | ForEach(self.tags.map { $0 }) { tag in 70 | Text(tag.name) 71 | } 72 | } else { 73 | Text("No tags selected") 74 | } 75 | }, header: { 76 | HStack { 77 | Text("Tags") 78 | Spacer() 79 | Button(action: { 80 | self.selectTagsOpen = true 81 | }) { 82 | Text("Select tags") 83 | } 84 | .font(.caption) 85 | .buttonStyle(.borderless) 86 | } 87 | }) 88 | } 89 | .navigationBarTitle("Create Bookmark") 90 | .toolbar { 91 | ToolbarItemGroup(placement: .navigationBarTrailing) { 92 | if self.requestInProgress { 93 | ProgressView() 94 | } else { 95 | Button(action: { 96 | if (self.url != "") { 97 | Task { 98 | self.requestInProgress = true 99 | let repository = LinkdingBookmarkRepository(bookmarkStore: self.bookmarkStore, tagStore: self.tagStore) 100 | let syncBookmark = self.bookmark != nil ? 101 | repository.updateBookmark(bookmark: self.bookmark!, url: self.url, title: self.title, description: self.description, isArchived: self.isArchived, unread: self.unread, shared: self.shared, tags: self.tags.map { $0.name }) : 102 | repository.createNewBookmark(url: self.url, title: self.title, description: self.description, isArchived: self.isArchived, unread: self.unread, shared: self.shared, tags: self.tags.map{ $0.name }) 103 | if self.linkdingAvailable { 104 | let sync = LinkdingSyncClient(tagStore: self.tagStore, bookmarkStore: self.bookmarkStore) 105 | do { 106 | try await sync.syncSingleBookmark(bookmark: syncBookmark) 107 | } catch (_) { 108 | self.requestInProgress = false 109 | self.onClose() 110 | } 111 | } 112 | self.requestInProgress = false 113 | self.onClose() 114 | } 115 | } 116 | }) { 117 | Image(systemName: "tray.and.arrow.down") 118 | } 119 | } 120 | } 121 | ToolbarItemGroup(placement: .navigationBarLeading) { 122 | Button(action: { 123 | self.onClose() 124 | }) { 125 | Image(systemName: "xmark") 126 | } 127 | } 128 | } 129 | .sheet(isPresented: self.$selectTagsOpen) { 130 | ShareBookmarkTagSelect(selectedTags: self.$tags) 131 | .environmentObject(self.tagStore) 132 | } 133 | .onAppear() { 134 | LinkdingPersistenceController.shared.setViewContextData(name: "viewContext", author: "ShareExtension") 135 | 136 | if let found = self.bookmarkStore.getByUrl(url: self.url) { 137 | self.bookmark = found 138 | self.title = found.title 139 | self.description = found.urlDescription 140 | self.isArchived = found.isArchived 141 | self.unread = found.unread 142 | self.shared = found.shared 143 | self.tags = Set(self.tagStore.getByNameList(names: found.tagNames)) 144 | } else { 145 | self.isArchived = self.defaultArchived 146 | self.unread = self.defaultUnread 147 | self.shared = self.defaultShared 148 | } 149 | 150 | let syncClient = LinkdingSyncClient(tagStore: self.tagStore, bookmarkStore: self.bookmarkStore) 151 | Task { 152 | if await syncClient.isBackendAvailable() { 153 | self.linkdingAvailable = true 154 | } else { 155 | self.linkdingAvailable = false 156 | } 157 | } 158 | } 159 | } 160 | .navigationViewStyle(.stack) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /ShareExtension/ShareBookmarkTagSelect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareBookmarkTagSelect.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import SwiftUI 7 | import CompanionApplication 8 | 9 | struct ShareBookmarkTagSelect: View { 10 | @EnvironmentObject var tagStore: LinkdingTagStore 11 | @Environment(\.presentationMode) private var presentationMode 12 | @Environment(\.dismissSearch) private var dismissSearch 13 | 14 | @Binding var selectedTags: Set 15 | 16 | var body: some View { 17 | NavigationView { 18 | CommonSelectListView( 19 | items: self.tagStore.tags, 20 | selectedItems: self.$selectedTags, 21 | createNotFoundHandler: self 22 | ) 23 | .navigationBarTitle("Select tags") 24 | .toolbar { 25 | ToolbarItemGroup(placement: .navigationBarTrailing) { 26 | Button(action: { 27 | self.presentationMode.wrappedValue.dismiss() 28 | }) { 29 | Text("Close") 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | 37 | extension ShareBookmarkTagSelect: CreateNotFoundItemHandler { 38 | func createItem(text: String) { 39 | let repository = LinkdingTagRepository(tagStore: self.tagStore) 40 | let createdTag = repository.createTag(tag: TagModel(name: text)) 41 | self.selectedTags.insert(createdTag) 42 | self.dismissSearch() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ShareExtension/ShareExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.bookmarkcompanion 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ShareExtension/ShareViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareViewController.swift 3 | // Created by Christian Wilhelm 4 | // 5 | 6 | import UIKit 7 | import SwiftUI 8 | import CompanionApplication 9 | 10 | class ShareViewController: UIViewController { 11 | private var sharedUrl: String = "" 12 | 13 | private func getSharedUrl() async -> String { 14 | for item in extensionContext!.inputItems as! [NSExtensionItem] { 15 | if let attachments = item.attachments { 16 | for itemProvider in attachments { 17 | if itemProvider.hasItemConformingToTypeIdentifier("public.url") { 18 | let item = try? await itemProvider.loadItem(forTypeIdentifier: "public.url", options: nil) 19 | return (item as! NSURL).absoluteURL!.absoluteString 20 | } 21 | } 22 | } 23 | } 24 | return "" 25 | } 26 | 27 | override func viewDidLoad() { 28 | super.viewDidLoad() 29 | 30 | Task { 31 | let sharedUrl = await self.getSharedUrl() 32 | let container = ShareBookmarkContainerView(onClose: { 33 | self.extensionContext!.completeRequest(returningItems: nil, completionHandler: nil) 34 | }, url: sharedUrl) 35 | let child = UIHostingController(rootView: container) 36 | 37 | self.view.addSubview(child.view) 38 | 39 | child.view.translatesAutoresizingMaskIntoConstraints = false 40 | child.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true 41 | child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true 42 | child.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true 43 | child.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true 44 | } 45 | } 46 | } 47 | --------------------------------------------------------------------------------