├── .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 |
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 | [](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 |
--------------------------------------------------------------------------------