├── Dokusho.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ ├── WorkspaceSettings.xcsettings │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcuserdata │ │ └── stef.xcuserdatad │ │ ├── IDEFindNavigatorScopes.plist │ │ └── WorkspaceSettings.xcsettings ├── xcshareddata │ └── xcschemes │ │ └── Dokusho.xcscheme └── xcuserdata │ └── stef.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── Dokusho ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── appstore1024.png │ │ ├── ipad152.png │ │ ├── ipad76.png │ │ ├── ipadNotification20.png │ │ ├── ipadNotification40.png │ │ ├── ipadPro167.png │ │ ├── ipadSettings29.png │ │ ├── ipadSettings58.png │ │ ├── ipadSpotlight40.png │ │ ├── ipadSpotlight80.png │ │ ├── iphone120.png │ │ ├── iphone180.png │ │ ├── mac1024.png │ │ ├── mac128.png │ │ ├── mac16.png │ │ ├── mac256.png │ │ ├── mac32.png │ │ ├── mac512.png │ │ ├── mac64.png │ │ ├── notification40.png │ │ ├── notification60.png │ │ ├── settings58.png │ │ ├── settings87.png │ │ ├── spotlight120.png │ │ └── spotlight80.png │ └── Contents.json ├── Dokusho.entitlements ├── DokushoApp.swift ├── Info.plist └── RootView.swift ├── Features ├── .gitignore ├── Package.swift ├── README.md ├── Sources │ ├── Backup │ │ ├── BackupManager.swift │ │ └── Views │ │ │ └── BackupImporter.swift │ ├── Common │ │ ├── Enums │ │ │ └── ActiveTab.swift │ │ ├── Extensions │ │ │ ├── Array.swift │ │ │ ├── Date.swift │ │ │ ├── EnvironmentValues.swift │ │ │ ├── Logger.swift │ │ │ ├── Nuke.swift │ │ │ ├── ObservableObject.swift │ │ │ ├── Optional.swift │ │ │ ├── Sequence.swift │ │ │ ├── String.swift │ │ │ ├── UIScreen.swift │ │ │ ├── URL.swift │ │ │ └── View.swift │ │ ├── PropertyWrapper │ │ │ └── Preferences.swift │ │ ├── Services │ │ │ └── DeviceOrientation.swift │ │ └── ViewModifier │ │ │ ├── GlowBorder.swift │ │ │ └── ZoomGesture.swift │ ├── DataKit │ │ ├── Extensions │ │ │ ├── Array.swift │ │ │ └── EnvironmentValues.swift │ │ ├── LibraryUpdater.swift │ │ ├── Models │ │ │ ├── Manga.swift │ │ │ ├── MangaChapter.swift │ │ │ ├── MangaCollection.swift │ │ │ └── Scraper.swift │ │ ├── Persistence.swift │ │ └── Requests │ │ │ ├── ChaptersHistoryRequest.swift │ │ │ ├── DetailedMangaCollectionRequest.swift │ │ │ ├── DetailedMangaInList.swift │ │ │ ├── DistinctMangaGenreRequest.swift │ │ │ ├── MangaChaptersRequest.swift │ │ │ ├── MangaCollectionRequest.swift │ │ │ ├── MangaDetailRequest.swift │ │ │ ├── MangaInCollectionsRequest.swift │ │ │ ├── OneMangaCollectionRequest.swift │ │ │ ├── ScraperRequest.swift │ │ │ └── ScraperWithMangaInCollection.swift │ ├── DynamicCollection │ │ ├── MangaForSourcePage.swift │ │ └── MangaInCollectionForGenre.swift │ ├── ExploreTab │ │ ├── ExploreTabView.swift │ │ ├── Screens │ │ │ ├── ExploreSourceView.swift │ │ │ └── SearchSourceListScreen.swift │ │ └── ViewModels │ │ │ ├── ExploreTabVM.swift │ │ │ └── SearchScraperVM.swift │ ├── HistoryTab │ │ └── HistoryTabView.swift │ ├── LibraryTab │ │ ├── Components │ │ │ └── MangaLibraryContextMenu.swift │ │ ├── LibraryTabView.swift │ │ └── Screens │ │ │ ├── ByGenre.swift │ │ │ ├── BySource.swift │ │ │ ├── CollectionPage.swift │ │ │ └── CollectionSettings.swift │ ├── MangaDetail │ │ ├── Components │ │ │ ├── ChapterListInformation.swift │ │ │ └── ChapterListRow.swift │ │ ├── MangaDetailView.swift │ │ └── ViewModels │ │ │ ├── ChapterListVM.swift │ │ │ └── MangaDetailVM.swift │ ├── MangaScraper │ │ ├── Extension │ │ │ ├── Array.swift │ │ │ ├── Date.swift │ │ │ └── String.swift │ │ ├── MangaScraperService.swift │ │ └── Sources │ │ │ ├── ALL │ │ │ └── MangaDex.swift │ │ │ ├── EN │ │ │ └── NepNep.swift │ │ │ └── Source.swift │ ├── Reader │ │ ├── Components │ │ │ ├── ChapterImageView.swift │ │ │ ├── HorizontalReaderView.swift │ │ │ └── VerticalReaderView.swift │ │ ├── ReaderManager.swift │ │ ├── ReaderVM.swift │ │ └── ReaderView.swift │ ├── SettingsTab │ │ ├── SettingsTabView.swift │ │ └── SettingsVM.swift │ └── SharedUI │ │ ├── Components │ │ ├── AdaptiveStack.swift │ │ ├── AsyncButton.swift │ │ ├── DebouncedSearchBar.swift │ │ ├── DebouncedTextField.swift │ │ ├── FlexibleView.swift │ │ ├── LibraryRefresher.swift │ │ ├── MangaCard.swift │ │ ├── MangaList.swift │ │ ├── RemoteImageCacheView.swift │ │ └── Toolbar │ │ │ └── AddButton.swift │ │ ├── ObservableObject │ │ └── FieldObserver.swift │ │ └── Shapes │ │ └── RoundedCorner.swift └── Tests │ └── MangaScraperTests │ └── MangaScraperTests.swift ├── LICENSE ├── Preview Content └── Preview Assets.xcassets │ └── Contents.json └── README.md /Dokusho.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Dokusho.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Dokusho.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Dokusho.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "grdb.swift", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/groue/GRDB.swift", 7 | "state" : { 8 | "revision" : "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", 9 | "version" : "6.29.3" 10 | } 11 | }, 12 | { 13 | "identity" : "grdbquery", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/groue/GRDBQuery", 16 | "state" : { 17 | "revision" : "be64298b4f9d70510226fa7e698aef84f41cec02", 18 | "version" : "0.7.0" 19 | } 20 | }, 21 | { 22 | "identity" : "jayson", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/muukii/JAYSON", 25 | "state" : { 26 | "revision" : "59e9a669b0d18a6536f3592a22a6d08538a718ec", 27 | "version" : "2.5.0" 28 | } 29 | }, 30 | { 31 | "identity" : "nuke", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/kean/Nuke", 34 | "state" : { 35 | "revision" : "93c8dc78fbc0aa3538a0db460eb389d4180af242", 36 | "version" : "11.3.1" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-collections", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/apple/swift-collections.git", 43 | "state" : { 44 | "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", 45 | "version" : "1.1.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swiftsoup", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/scinfu/SwiftSoup.git", 52 | "state" : { 53 | "branch" : "master", 54 | "revision" : "028487d4a8a291b2fe1b4392b5425b6172056148" 55 | } 56 | }, 57 | { 58 | "identity" : "swiftuilayouts", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/apptekstudios/SwiftUILayouts", 61 | "state" : { 62 | "branch" : "main", 63 | "revision" : "de1da15d1afee3b41dc628b22a4cfef381d3986f" 64 | } 65 | } 66 | ], 67 | "version" : 2 68 | } 69 | -------------------------------------------------------------------------------- /Dokusho.xcodeproj/project.xcworkspace/xcuserdata/stef.xcuserdatad/IDEFindNavigatorScopes.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Dokusho.xcodeproj/project.xcworkspace/xcuserdata/stef.xcuserdatad/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildLocationStyle 6 | UseAppPreferences 7 | CustomBuildLocationType 8 | RelativeToDerivedData 9 | DerivedDataLocationStyle 10 | Default 11 | IssueFilterStyle 12 | ShowActiveSchemeOnly 13 | LiveSourceIssuesEnabled 14 | 15 | ShowSharedSchemesAutomaticallyEnabled 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Dokusho.xcodeproj/xcshareddata/xcschemes/Dokusho.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 57 | 58 | 61 | 62 | 65 | 66 | 67 | 68 | 74 | 76 | 82 | 83 | 84 | 85 | 87 | 88 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /Dokusho.xcodeproj/xcuserdata/stef.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /Dokusho/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 | -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "notification40.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "notification60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "settings58.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "settings87.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "spotlight80.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "spotlight120.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "iphone120.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "iphone180.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "ipadNotification20.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "ipadNotification40.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "ipadSettings29.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "ipadSettings58.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "ipadSpotlight40.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "ipadSpotlight80.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "ipad76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "ipad152.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "ipadPro167.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "appstore1024.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | }, 111 | { 112 | "filename" : "mac16.png", 113 | "idiom" : "mac", 114 | "scale" : "1x", 115 | "size" : "16x16" 116 | }, 117 | { 118 | "filename" : "mac32.png", 119 | "idiom" : "mac", 120 | "scale" : "2x", 121 | "size" : "16x16" 122 | }, 123 | { 124 | "filename" : "mac32.png", 125 | "idiom" : "mac", 126 | "scale" : "1x", 127 | "size" : "32x32" 128 | }, 129 | { 130 | "filename" : "mac64.png", 131 | "idiom" : "mac", 132 | "scale" : "2x", 133 | "size" : "32x32" 134 | }, 135 | { 136 | "filename" : "mac128.png", 137 | "idiom" : "mac", 138 | "scale" : "1x", 139 | "size" : "128x128" 140 | }, 141 | { 142 | "filename" : "mac256.png", 143 | "idiom" : "mac", 144 | "scale" : "2x", 145 | "size" : "128x128" 146 | }, 147 | { 148 | "filename" : "mac256.png", 149 | "idiom" : "mac", 150 | "scale" : "1x", 151 | "size" : "256x256" 152 | }, 153 | { 154 | "filename" : "mac512.png", 155 | "idiom" : "mac", 156 | "scale" : "2x", 157 | "size" : "256x256" 158 | }, 159 | { 160 | "filename" : "mac512.png", 161 | "idiom" : "mac", 162 | "scale" : "1x", 163 | "size" : "512x512" 164 | }, 165 | { 166 | "filename" : "mac1024.png", 167 | "idiom" : "mac", 168 | "scale" : "2x", 169 | "size" : "512x512" 170 | } 171 | ], 172 | "info" : { 173 | "author" : "xcode", 174 | "version" : 1 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/appstore1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/appstore1024.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/ipad152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/ipad152.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/ipad76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/ipad76.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/ipadNotification20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/ipadNotification20.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/ipadNotification40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/ipadNotification40.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/ipadPro167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/ipadPro167.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/ipadSettings29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/ipadSettings29.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/ipadSettings58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/ipadSettings58.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/ipadSpotlight40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/ipadSpotlight40.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/ipadSpotlight80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/ipadSpotlight80.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/iphone120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/iphone120.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/iphone180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/iphone180.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/mac1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/mac1024.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/mac128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/mac128.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/mac16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/mac16.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/mac256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/mac256.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/mac32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/mac32.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/mac512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/mac512.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/mac64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/mac64.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/notification40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/notification40.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/notification60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/notification60.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/settings58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/settings58.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/settings87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/settings87.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/spotlight120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/spotlight120.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/AppIcon.appiconset/spotlight80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzSiAz/Dokusho/86d54692c168331430aacabef87cb92e2985f6af/Dokusho/Assets.xcassets/AppIcon.appiconset/spotlight80.png -------------------------------------------------------------------------------- /Dokusho/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Dokusho/Dokusho.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.security.application-groups 8 | 9 | group.tech.azsiaz.Dokusho 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Dokusho/DokushoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DokushoApp.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 17/08/2021. 6 | // 7 | 8 | import SwiftUI 9 | //import TelemetryClient 10 | import DataKit 11 | import Backup 12 | 13 | @main 14 | struct DokushoApp: App { 15 | @StateObject var libraryUpdater = LibraryUpdater.shared 16 | @StateObject var backupManager = BackupManager.shared 17 | 18 | var body: some Scene { 19 | WindowGroup { 20 | RootView() 21 | .environment(\.appDatabase, .shared) 22 | .environmentObject(libraryUpdater) 23 | .environmentObject(backupManager) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Dokusho/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ITSAppUsesNonExemptEncryption 6 | 7 | NSAppTransportSecurity 8 | 9 | NSExceptionDomains 10 | 11 | manga4life.com 12 | 13 | NSExceptionAllowsInsecureHTTPLoads 14 | 15 | NSIncludesSubdomains 16 | 17 | 18 | mangasee123.com 19 | 20 | NSExceptionAllowsInsecureHTTPLoads 21 | 22 | NSIncludesSubdomains 23 | 24 | 25 | 26 | 27 | NSUserActivityTypes 28 | 29 | ChooseCollectionIntent 30 | 31 | UIBackgroundModes 32 | 33 | remote-notification 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Dokusho/RootView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootView.swift 3 | // RootView 4 | // 5 | // Created by Stephan Deumier on 13/08/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import MangaScraper 11 | import SettingsTab 12 | import HistoryTab 13 | import Backup 14 | import LibraryTab 15 | import ExploreTab 16 | import Common 17 | 18 | struct RootView: View { 19 | @EnvironmentObject var backupManager: BackupManager 20 | @AppStorage("selectedTab") private var tab: ActiveTab = .library 21 | 22 | var body: some View { 23 | if backupManager.isImporting { BackupImporter(backupManager: backupManager) } 24 | else if UIScreen.isLargeScreen { iPadView() } 25 | else { iPhoneView() } 26 | } 27 | 28 | @ViewBuilder 29 | func iPhoneView() -> some View { 30 | TabView(selection: $tab) { 31 | LibraryTabView() 32 | .tabItem { Label("Library", systemImage: "books.vertical") } 33 | .tag(ActiveTab.library) 34 | 35 | HistoryTabView() 36 | .tabItem { Label("History", systemImage: "clock") } 37 | .tag(ActiveTab.history) 38 | 39 | ExploreTabView() 40 | .tabItem { Label("Explore", systemImage: "safari") } 41 | .tag(ActiveTab.explore) 42 | 43 | SettingsTabView() 44 | .tabItem { Label("Settings", systemImage: "gear") } 45 | .tag(ActiveTab.settings) 46 | } 47 | } 48 | 49 | // TODO: Change to double sidebar to avoid using a not ergonomic tab bar for iPadOS & MacOS 50 | @ViewBuilder 51 | func iPadView() -> some View { 52 | iPhoneView() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Features/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Features/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Features", 8 | platforms: [.iOS(.v18)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library(name: "Common", targets: ["Common"]), 12 | .library(name: "SharedUI", targets: ["SharedUI"]), 13 | .library(name: "DataKit", targets: ["DataKit"]), 14 | .library(name: "Reader", targets: ["Reader"]), 15 | .library(name: "MangaDetail", targets: ["MangaDetail"]), 16 | .library(name: "MangaScraper", targets: ["MangaScraper"]), 17 | .library(name: "SettingsTab", targets: ["SettingsTab"]), 18 | .library(name: "HistoryTab", targets: ["HistoryTab"]), 19 | .library(name: "Backup", targets: ["Backup"]), 20 | .library(name: "DynamicCollection", targets: ["DynamicCollection"]), 21 | .library(name: "LibraryTab", targets: ["LibraryTab"]), 22 | .library(name: "ExploreTab", targets: ["ExploreTab"]), 23 | ], 24 | dependencies: [ 25 | .package(url: "https://github.com/groue/GRDB.swift.git", exact: "6.29.3"), 26 | .package(url: "https://github.com/groue/GRDBQuery.git", exact: "0.7.0"), 27 | .package(url: "https://github.com/kean/Nuke", exact: "11.3.1"), 28 | .package(url: "https://github.com/scinfu/SwiftSoup.git", branch: "master"), 29 | .package(url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.0.3")), 30 | .package(url: "https://github.com/muukii/JAYSON", exact: "2.5.0"), 31 | .package(url: "https://github.com/apptekstudios/SwiftUILayouts", branch: "main"), 32 | ], 33 | targets: [ 34 | .target( 35 | name: "MangaScraper", 36 | dependencies: [ 37 | .byName(name: "SwiftSoup"), 38 | .byName(name: "JAYSON"), 39 | .product(name: "Collections", package: "swift-collections") 40 | ] 41 | ), 42 | .testTarget(name: "MangaScraperTests", dependencies: ["MangaScraper"]), 43 | 44 | .target( 45 | name: "Common", 46 | dependencies: [ 47 | .product(name: "Nuke", package: "Nuke") 48 | ] 49 | ), 50 | 51 | .target( 52 | name: "SharedUI", 53 | dependencies: [ 54 | .byName(name: "Common"), 55 | .product(name: "Nuke", package: "Nuke"), 56 | .product(name: "NukeUI", package: "Nuke") 57 | ] 58 | ), 59 | 60 | .target( 61 | name: "Reader", 62 | dependencies: [ 63 | .byName(name: "Common"), 64 | .byName(name: "DataKit"), 65 | .byName(name: "MangaScraper"), 66 | .product(name: "Nuke", package: "Nuke"), 67 | .product(name: "NukeUI", package: "Nuke") 68 | ] 69 | ), 70 | 71 | .target( 72 | name: "DataKit", 73 | dependencies: [ 74 | .byName(name: "Common"), 75 | .byName(name: "MangaScraper"), 76 | .product(name: "GRDB", package: "GRDB.swift"), 77 | .byName(name: "GRDBQuery") 78 | ] 79 | ), 80 | 81 | .target( 82 | name: "MangaDetail", 83 | dependencies: [ 84 | .byName(name: "MangaScraper"), 85 | .byName(name: "DataKit"), 86 | .byName(name: "GRDBQuery"), 87 | .byName(name: "Common"), 88 | .byName(name: "SharedUI"), 89 | .byName(name: "Reader"), 90 | .byName(name: "SwiftUILayouts"), 91 | ] 92 | ), 93 | 94 | .target( 95 | name: "SettingsTab", 96 | dependencies: [ 97 | .byName(name: "DataKit"), 98 | .byName(name: "Common"), 99 | .byName(name: "SharedUI"), 100 | .byName(name: "Backup"), 101 | .product(name: "Nuke", package: "Nuke") 102 | ] 103 | ), 104 | 105 | .target( 106 | name: "HistoryTab", 107 | dependencies: [ 108 | .byName(name: "DataKit"), 109 | .byName(name: "GRDBQuery"), 110 | .byName(name: "SharedUI"), 111 | .byName(name: "MangaDetail") 112 | ] 113 | ), 114 | 115 | .target( 116 | name: "Backup", 117 | dependencies: [ 118 | .byName(name: "DataKit"), 119 | .byName(name: "Common"), 120 | ] 121 | ), 122 | 123 | .target( 124 | name: "DynamicCollection", 125 | dependencies: [ 126 | .byName(name: "DataKit"), 127 | .byName(name: "Common"), 128 | .byName(name: "GRDBQuery"), 129 | .byName(name: "SharedUI"), 130 | .byName(name: "MangaDetail"), 131 | .byName(name: "MangaScraper") 132 | ] 133 | ), 134 | 135 | .target( 136 | name: "LibraryTab", 137 | dependencies: [ 138 | .byName(name: "DataKit"), 139 | .byName(name: "Common"), 140 | .product(name: "GRDB", package: "GRDB.swift"), 141 | .byName(name: "GRDBQuery"), 142 | .byName(name: "SharedUI"), 143 | .byName(name: "MangaDetail"), 144 | .byName(name: "MangaScraper"), 145 | .byName(name: "DynamicCollection"), 146 | ] 147 | ), 148 | 149 | .target( 150 | name: "ExploreTab", 151 | dependencies: [ 152 | .byName(name: "DataKit"), 153 | .byName(name: "Common"), 154 | .product(name: "GRDB", package: "GRDB.swift"), 155 | .byName(name: "GRDBQuery"), 156 | .byName(name: "SharedUI"), 157 | .byName(name: "MangaDetail"), 158 | .byName(name: "MangaScraper"), 159 | .product(name: "Collections", package: "swift-collections"), 160 | ] 161 | ), 162 | ], 163 | swiftLanguageModes: [.version("6")] 164 | ) 165 | -------------------------------------------------------------------------------- /Features/README.md: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Features/Sources/Backup/BackupManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Backup.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 21/12/2021. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | import SwiftUI 11 | @preconcurrency import UniformTypeIdentifiers 12 | import MangaScraper 13 | import DataKit 14 | import Common 15 | 16 | public struct Backup: FileDocument { 17 | public static let readableContentTypes = [UTType.json] 18 | public static let writableContentTypes = [UTType.json] 19 | 20 | var data: BackupData 21 | 22 | nonisolated public init(configuration: ReadConfiguration) throws { 23 | throw "Not done" 24 | } 25 | 26 | 27 | public init(data: BackupData) { 28 | self.data = data 29 | } 30 | 31 | public func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { 32 | let data = try JSONEncoder().encode(data) 33 | 34 | return FileWrapper(regularFileWithContents: data) 35 | } 36 | } 37 | 38 | public struct BackupData: Codable, Sendable { 39 | var collections: [BackupCollectionData] 40 | var scrapers: [Scraper] 41 | } 42 | 43 | public struct BackupCollectionData: Codable, Sendable { 44 | var collection: MangaCollection 45 | var mangas: [MangaWithChapters] 46 | } 47 | 48 | public struct MangaWithChapters: Codable, Sendable { 49 | var manga: Manga 50 | var chapters: [MangaChapter] 51 | } 52 | 53 | 54 | public struct BackupTask: Sendable { 55 | var mangaBackup: MangaWithChapters 56 | var collection: MangaCollection 57 | } 58 | 59 | public typealias BackupResult = Result 60 | 61 | public class BackupManager: ObservableObject { 62 | nonisolated(unsafe) public static let shared = BackupManager() 63 | 64 | private let database = AppDatabase.shared.database 65 | 66 | @Published public var isImporting: Bool = false 67 | @Published public var total: Double = 0 68 | @Published public var progress: Double = 0 69 | 70 | public init() {} 71 | 72 | public func createBackup() -> BackupData { 73 | var backupCollections = [BackupCollectionData]() 74 | var scrapers = [Scraper]() 75 | 76 | do { 77 | try database.read { db in 78 | scrapers = try Scraper.all().fetchAll(db) 79 | let collections = try MangaCollection.all().fetchAll(db) 80 | 81 | for collection in collections { 82 | let mangas = try Manga.all().forCollectionId(collection.id).fetchAll(db) 83 | var mangasBackup = [MangaWithChapters]() 84 | 85 | for manga in mangas { 86 | let chapters = try MangaChapter.all().forMangaId(manga.id).fetchAll(db) 87 | mangasBackup.append(.init(manga: manga, chapters: chapters)) 88 | } 89 | 90 | backupCollections.append(.init(collection: collection, mangas: mangasBackup)) 91 | } 92 | } 93 | } catch(let err) { 94 | print(err) 95 | } 96 | 97 | return .init(collections: backupCollections, scrapers: scrapers) 98 | } 99 | 100 | @MainActor 101 | public func importBackup(backup: BackupData) async { 102 | 103 | toggleIsImporting() 104 | 105 | await withTaskGroup(of: BackupResult.self) { group in 106 | 107 | for scraper in backup.scrapers { 108 | let _ = try? await database.write({ try Scraper.fetchOrCreateFromBackup(db: $0, backup: scraper) }) 109 | } 110 | 111 | for collectionBackup in backup.collections { 112 | guard let collection = try? await database.write({ try MangaCollection.fetchOrCreateFromBackup(db: $0, backup: collectionBackup.collection) }) else { continue } 113 | 114 | for mangaBackup in collectionBackup.mangas { 115 | group.addTask(priority: .background) { 116 | return .success(BackupTask(mangaBackup: mangaBackup, collection: collection)) 117 | } 118 | 119 | withAnimation { 120 | self.total += 1 121 | } 122 | } 123 | } 124 | 125 | 126 | for await taskResult in group { 127 | switch(taskResult) { 128 | case .failure(let error): Logger.backup.error("\(error.localizedDescription)") 129 | case .success(let task): 130 | Logger.backup.info("Restoring \(task.mangaBackup.manga.title)") 131 | do { 132 | try await database.write { db in 133 | let _ = try task.mangaBackup.manga.saved(db) 134 | try task.mangaBackup.chapters.forEach { try $0.save(db) } 135 | } 136 | 137 | withAnimation { 138 | self.progress += 1 139 | } 140 | } catch(let err) { 141 | print(err) 142 | } 143 | } 144 | } 145 | } 146 | 147 | toggleIsImporting() 148 | } 149 | 150 | @MainActor 151 | func toggleIsImporting() { 152 | withAnimation { 153 | self.isImporting.toggle() 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Features/Sources/Backup/Views/BackupImporter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Stef on 05/06/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct BackupImporter: View { 11 | @ObservedObject var backupManager: BackupManager 12 | 13 | public init(backupManager: BackupManager) { 14 | _backupManager = .init(wrappedValue: backupManager) 15 | } 16 | 17 | public var body: some View { 18 | ProgressView("Importing backup", value: backupManager.progress, total: backupManager.total) 19 | .padding() 20 | } 21 | } 22 | 23 | struct SwiftUIView_Previews: PreviewProvider { 24 | static var previews: some View { 25 | BackupImporter(backupManager: .shared) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Features/Sources/Common/Enums/ActiveTab.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActiveTab.swift 3 | // 4 | // 5 | // Created by Stephan Deumier on 06/06/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum ActiveTab: String { 11 | case explore, library, history, settings 12 | } 13 | -------------------------------------------------------------------------------- /Features/Sources/Common/Extensions/Array.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array.swift 3 | // Hanako 4 | // 5 | // Created by Stephan Deumier on 30/12/2020. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public extension Array { 12 | func chunked(into size: Int) -> [[Element]] { 13 | return stride(from: 0, to: count, by: size).map { 14 | Array(self[$0 ..< Swift.min($0 + size, count)]) 15 | } 16 | } 17 | 18 | func get(index: Int) -> Element? { 19 | if 0 <= index && index < count { 20 | return self[index] 21 | } else { 22 | return nil 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Features/Sources/Common/Extensions/Date.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date.swift 3 | // Hanako 4 | // 5 | // Created by Stephan Deumier on 07/01/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension Date { 11 | static func from(year: Int, month: Int, day: Int, calendarType: Calendar.Identifier = .gregorian) -> Date { 12 | var dateComponents = DateComponents() 13 | dateComponents.year = year 14 | dateComponents.month = month 15 | dateComponents.day = day 16 | 17 | return Calendar(identifier: calendarType).date(from: dateComponents)! 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Features/Sources/Common/Extensions/EnvironmentValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentValues.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 20/10/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public extension UIApplication { 12 | var keyWindow: UIWindow? { 13 | connectedScenes 14 | .compactMap { 15 | $0 as? UIWindowScene 16 | } 17 | .flatMap { 18 | $0.windows 19 | } 20 | .first { 21 | $0.isKeyWindow 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Features/Sources/Common/Extensions/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // Dokusho (iOS) 4 | // 5 | // Created by Stephan Deumier on 05/06/2021. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | public extension Logger { 12 | private static let subsystem = Bundle.main.bundleIdentifier! 13 | 14 | static let persistence = Logger(subsystem: subsystem, category: "db") 15 | static let migration = Logger(subsystem: subsystem, category: "db.migration") 16 | static let reader = Logger(subsystem: subsystem, category: "reader") 17 | static let backup = Logger(subsystem: subsystem, category: "backup") 18 | } 19 | -------------------------------------------------------------------------------- /Features/Sources/Common/Extensions/Nuke.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImagePipeline.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 20/10/2021. 6 | // 7 | 8 | import Foundation 9 | import Nuke 10 | 11 | public extension ImagePipeline { 12 | static var inMemory: ImagePipeline { 13 | return ImagePipeline { $0.imageCache = ImageCache() } 14 | } 15 | 16 | static var coverCache: ImagePipeline { 17 | ImagePipeline { 18 | let dataLoader: DataLoader = { 19 | let config = URLSessionConfiguration.default 20 | config.urlCache = nil 21 | return DataLoader(configuration: config) 22 | }() 23 | 24 | $0.dataCache = DataCache.DiskCover 25 | $0.dataLoader = dataLoader 26 | $0.isRateLimiterEnabled = true 27 | $0.dataCachePolicy = .automatic 28 | } 29 | } 30 | } 31 | 32 | public extension DataCache { 33 | static var DiskCover: DataCache? { 34 | let dataCache = try? DataCache(name: "tech.azsiaz.Dokusho.cover") 35 | dataCache?.sizeLimit = 1024 * 1024 * 1500 36 | 37 | return dataCache 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Features/Sources/Common/Extensions/ObservableObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stephan Deumier on 31/05/2022. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public extension ObservableObject { 12 | func animateAsyncChange(_ animation: Animation? = .default, _ change: @Sendable @escaping () -> Void) async { 13 | await MainActor.run { 14 | withAnimation(animation) { 15 | change() 16 | } 17 | } 18 | } 19 | 20 | func asyncChange(_ change: @Sendable @escaping () -> Void) async { 21 | await MainActor.run { 22 | change() 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Features/Sources/Common/Extensions/Optional.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSSet.swift 3 | // NSSet 4 | // 5 | // Created by Stephan Deumier on 18/08/2021. 6 | // 7 | 8 | import Foundation 9 | import CoreData 10 | 11 | public extension Optional where Wrapped == NSSet { 12 | func asSet(of: T.Type) -> Set { 13 | return self as! Set 14 | } 15 | } 16 | 17 | public extension Optional where Wrapped == NSSet { 18 | func array(of: T.Type) -> [T] { 19 | if let set = self as? Set { 20 | return Array(set) 21 | } 22 | return [T]() 23 | } 24 | } 25 | 26 | public extension Optional where Wrapped: Sequence { 27 | func sorted(by keyPath: KeyPath) -> [Wrapped.Element] { 28 | if let self = self { 29 | return self.sorted { a, b in 30 | return a[keyPath: keyPath] < b[keyPath: keyPath] 31 | } 32 | } 33 | 34 | return [Wrapped.Element]() 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /Features/Sources/Common/Extensions/Sequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stef on 11/05/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension Sequence { 11 | func sorted( 12 | by keyPath: KeyPath, 13 | using comparator: (T, T) -> Bool = (<) 14 | ) -> [Element] { 15 | sorted { a, b in 16 | comparator(a[keyPath: keyPath], b[keyPath: keyPath]) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Features/Sources/Common/Extensions/String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 23/12/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String: @retroactive Identifiable { 11 | public var id: String { self } 12 | } 13 | -------------------------------------------------------------------------------- /Features/Sources/Common/Extensions/UIScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 02/07/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension UIScreen { 11 | static var isLargeScreen: Bool { 12 | return UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Features/Sources/Common/Extensions/URL.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL.swift 3 | // URL 4 | // 5 | // Created by Stephan Deumier on 25/08/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension URL { 11 | static func getDocumentsDirectory() -> URL { 12 | let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) 13 | return paths[0] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Features/Sources/Common/Extensions/View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stef on 11/05/2022. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | fileprivate struct SizePreferenceKey: PreferenceKey { 12 | nonisolated(unsafe) static var defaultValue: CGSize = .zero 13 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} 14 | } 15 | 16 | public extension View { 17 | func sheetSizeAware(item: Binding, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Item) -> Content) -> some View where Item: Identifiable, Content: View { 18 | if UIScreen.isLargeScreen { 19 | return AnyView(self.fullScreenCover(item: item, onDismiss: onDismiss) { item in 20 | content(item) 21 | }) 22 | } 23 | else { 24 | return AnyView(self.sheet(item: item, onDismiss: onDismiss) { item in 25 | content(item) 26 | }) 27 | } 28 | } 29 | 30 | func glowBorder(color: Color, lineWidth: Int) -> some View { 31 | self.modifier(GlowBorder(color: color, lineWidth: lineWidth)) 32 | } 33 | 34 | 35 | func addPinchAndPan(isZooming: Binding) -> some View { 36 | self.modifier(PinchAndPanImage(isZooming: isZooming)) 37 | } 38 | 39 | func readSize(global: Bool = false, onChange: @escaping (CGSize) -> Void) -> some View { 40 | background( 41 | GeometryReader { geometryProxy in 42 | Color.clear 43 | .preference(key: SizePreferenceKey.self, value: global ? geometryProxy.frame(in: .global).size : geometryProxy.size) 44 | } 45 | ) 46 | .onPreferenceChange(SizePreferenceKey.self, perform: onChange) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Features/Sources/Common/PropertyWrapper/Preferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Preferences.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 03/05/2022. 6 | // 7 | 8 | @preconcurrency import Foundation 9 | @preconcurrency import Combine 10 | import SwiftUI 11 | 12 | @MainActor 13 | @propertyWrapper 14 | public struct UserDefault { 15 | let key: String 16 | let defaultValue: Value 17 | 18 | public var wrappedValue: Value { 19 | get { fatalError("Wrapped value should not be used.") } 20 | set { fatalError("Wrapped value should not be used.") } 21 | } 22 | 23 | public init(wrappedValue: Value, _ key: String) { 24 | self.defaultValue = wrappedValue 25 | self.key = key 26 | } 27 | 28 | public static subscript( 29 | _enclosingInstance instance: Preferences, 30 | wrapped wrappedKeyPath: ReferenceWritableKeyPath, 31 | storage storageKeyPath: ReferenceWritableKeyPath 32 | ) -> Value { 33 | get { 34 | let container = instance.userDefaults 35 | let key = instance[keyPath: storageKeyPath].key 36 | let defaultValue = instance[keyPath: storageKeyPath].defaultValue 37 | return container.object(forKey: key) as? Value ?? defaultValue 38 | } 39 | set { 40 | let container = instance.userDefaults 41 | let key = instance[keyPath: storageKeyPath].key 42 | container.set(newValue, forKey: key) 43 | instance.preferencesChangedSubject.send(wrappedKeyPath) 44 | } 45 | } 46 | } 47 | 48 | @preconcurrency 49 | public final class PublisherObservableObject: ObservableObject { 50 | 51 | var subscriber: AnyCancellable? 52 | 53 | public init(publisher: AnyPublisher) { 54 | subscriber = publisher.sink(receiveValue: { [weak self] _ in 55 | self?.objectWillChange.send() 56 | }) 57 | } 58 | } 59 | 60 | @MainActor 61 | public final class Preferences: Sendable { 62 | 63 | public static let standard = Preferences(userDefaults: .standard) 64 | fileprivate let userDefaults: UserDefaults 65 | 66 | /// Sends through the changed key path whenever a change occurs. 67 | var preferencesChangedSubject = PassthroughSubject() 68 | 69 | public init(userDefaults: UserDefaults) { 70 | self.userDefaults = userDefaults 71 | } 72 | 73 | @UserDefault("USE_NEW_HORIZONTAL_READER") 74 | public var useNewHorizontalReader: Bool = false 75 | 76 | @UserDefault("USE_NEW_VERTICAL_READER") 77 | public var useNewVerticalReader: Bool = false 78 | 79 | @UserDefault("ONLY_UPDATE_ALL_READ") 80 | public var onlyUpdateAllRead: Bool = true 81 | 82 | @UserDefault("NUMBER_OF_PRELOADED_IMAGES") 83 | public var numberOfPreloadedImages: Int = 3 84 | } 85 | 86 | @MainActor 87 | @propertyWrapper 88 | public struct Preference: DynamicProperty { 89 | 90 | @ObservedObject private var preferencesObserver: PublisherObservableObject 91 | private let keyPath: ReferenceWritableKeyPath 92 | private let preferences: Preferences 93 | 94 | public init(_ keyPath: ReferenceWritableKeyPath, preferences: Preferences = .standard) { 95 | self.keyPath = keyPath 96 | self.preferences = preferences 97 | let publisher = preferences 98 | .preferencesChangedSubject 99 | .filter { changedKeyPath in 100 | changedKeyPath == keyPath 101 | }.map { _ in () } 102 | .eraseToAnyPublisher() 103 | self.preferencesObserver = .init(publisher: publisher) 104 | } 105 | 106 | public var wrappedValue: Value { 107 | get { preferences[keyPath: keyPath] } 108 | nonmutating set { preferences[keyPath: keyPath] = newValue } 109 | } 110 | 111 | public var projectedValue: Binding { 112 | Binding( 113 | get: { wrappedValue }, 114 | set: { wrappedValue = $0 } 115 | ) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Features/Sources/Common/Services/DeviceOrientation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeviceOrientation.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 23/09/2021. 6 | // 7 | 8 | import SwiftUI 9 | @preconcurrency import Combine 10 | 11 | @MainActor 12 | public final class DeviceOrientation: ObservableObject { 13 | public enum Orientation { 14 | case portrait, landscape 15 | } 16 | 17 | @Published public var orientation: Orientation 18 | 19 | private var listener: AnyCancellable? 20 | 21 | public init() { 22 | orientation = UIDevice.current.orientation.isLandscape ? .landscape : .portrait 23 | listener = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification) 24 | .compactMap { ($0.object as? UIDevice)?.orientation } 25 | .compactMap { deviceOrientation -> Orientation? in 26 | if deviceOrientation.isPortrait { 27 | return .portrait 28 | } else if deviceOrientation.isLandscape { 29 | return .landscape 30 | } else { 31 | return nil 32 | } 33 | } 34 | .assign(to: \.orientation, on: self) 35 | } 36 | 37 | deinit { 38 | listener?.cancel() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Features/Sources/Common/ViewModifier/GlowBorder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GlowBorder.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 24/11/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct GlowBorder: ViewModifier { 11 | var color: Color 12 | var lineWidth: Int 13 | 14 | public func body(content: Content) -> some View { 15 | applyShadow(content: AnyView(content), lineWidth: lineWidth) 16 | } 17 | 18 | func applyShadow(content: AnyView, lineWidth: Int) -> AnyView { 19 | if lineWidth == 0 { 20 | return content 21 | } else { 22 | return applyShadow(content: AnyView(content.shadow(color: color, radius: 1)), lineWidth: lineWidth - 1) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Features/Sources/Common/ViewModifier/ZoomGesture.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct PinchAndPanImage: ViewModifier { 4 | // For Drag Gesture 5 | @State var size: CGSize = .zero 6 | @State var offset: CGSize = .zero 7 | @State var lastOffset: CGSize = .zero 8 | 9 | // For magnification Gesture 10 | @State var scale: CGFloat = 1 11 | @State var lastScale: CGFloat = 1 12 | 13 | @Binding var isZooming: Bool 14 | 15 | public func body(content: Content) -> some View { 16 | content 17 | .readSize { size = $0 } 18 | .scaleEffect(scale < 1 ? 1 : scale) 19 | .offset(offset) 20 | .gesture(magnificationGesture().simultaneously(with: dragGesture()).simultaneously(with: TapGesture(count: 2).onEnded(reset))) 21 | } 22 | 23 | func magnificationGesture() -> some Gesture { 24 | MagnificationGesture() 25 | .onChanged({ value in 26 | isZooming = true 27 | // MARK: It Starts With Existing Scaling which is 1 28 | // Removing That to Retreive Exact Scaling 29 | scale = lastScale + (value - 1) 30 | }).onEnded({ value in 31 | lastScale = scale 32 | if scale == 1 { 33 | isZooming = false 34 | } 35 | }) 36 | } 37 | 38 | func dragGesture() -> some Gesture { 39 | DragGesture(minimumDistance: getMinimalDistance(), coordinateSpace: .local) 40 | .onChanged({ value in 41 | offset = CGSize(width: lastOffset.width + value.translation.width, height: lastOffset.height + value.translation.height) 42 | }).onEnded({ value in 43 | lastOffset = offset 44 | }) 45 | } 46 | 47 | func reset() { 48 | withAnimation(.easeIn(duration: 0.25)) { 49 | scale = 1 50 | offset = .zero 51 | lastScale = scale 52 | lastOffset = offset 53 | isZooming = false 54 | } 55 | } 56 | 57 | func getMinimalDistance() -> Double { 58 | scale > 1 ? 0 : 10000 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Extensions/Array.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stef on 11/05/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array where Element == MangaChapter { 11 | func next(index: Index) -> Element? { 12 | let newIdx = index.advanced(by: 1) 13 | print(newIdx) 14 | guard newIdx <= index else { return nil } 15 | return self[newIdx] 16 | } 17 | 18 | func prev(index: Index) -> Element? { 19 | let newIdx = index.advanced(by: -1) 20 | print(newIdx) 21 | guard newIdx <= endIndex else { return nil } 22 | return self[newIdx] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Extensions/EnvironmentValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stef on 11/05/2022. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | private struct AppDatabaseKey: EnvironmentKey { 12 | static var defaultValue: AppDatabase { .makeEmpty() } 13 | } 14 | 15 | public extension EnvironmentValues { 16 | var appDatabase: AppDatabase { 17 | get { self[AppDatabaseKey.self] } 18 | set { self[AppDatabaseKey.self] = newValue } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/LibraryUpdater.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryVM.swift 3 | // Dokusho (iOS) 4 | // 5 | // Created by Stephan Deumier on 22/06/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import MangaScraper 11 | import OSLog 12 | import Common 13 | 14 | @MainActor 15 | public class LibraryUpdater: ObservableObject { 16 | public static let shared = LibraryUpdater() 17 | 18 | public struct RefreshStatus: Sendable { 19 | public var isRefreshing: Bool 20 | public var refreshProgress: Double 21 | public var refreshCount: Double 22 | public var refreshTitle: String 23 | public var collectionId: MangaCollection.ID 24 | } 25 | 26 | public struct RefreshData : Sendable { 27 | public var source: Source 28 | public var toRefresh: RefreshManga 29 | } 30 | 31 | private let database = AppDatabase.shared.database 32 | private var refreshStatus: [MangaCollection.ID: Bool] = [:] 33 | 34 | public func refreshCollection(collection: MangaCollection, onlyAllRead: Bool = true) async throws { 35 | guard refreshStatus[collection.id] == nil else { return } 36 | 37 | await MainActor.run { 38 | UIApplication.shared.isIdleTimerDisabled = true 39 | } 40 | 41 | updateRefreshStatus(collectionID: collection.id, refreshing: true) 42 | 43 | let data = try await database.read { [collection] db in 44 | try Manga.fetchForUpdate(db, collectionId: collection.id, onlyAllRead: onlyAllRead) 45 | } 46 | print("---------------------Fetching--------------------------") 47 | 48 | if data.count != 0 { 49 | try await withThrowingTaskGroup(of: RefreshData.self) { group in 50 | for row in data { 51 | guard let source = row.scraper.asSource() else { throw "Source not found from scraper with id: \(row.scraper.id)" } 52 | 53 | _ = group.addTaskUnlessCancelled(priority: .background) { 54 | return .init(source: source, toRefresh: row) 55 | } 56 | } 57 | 58 | for try await data in group { 59 | await Task.yield() 60 | 61 | do { 62 | let mangaSource = try await data.source.fetchMangaDetail(id: data.toRefresh.mangaId) 63 | 64 | let _ = try await database.write { db in 65 | try Manga.updateFromSource(db: db, scraper: data.toRefresh.scraper, data: mangaSource) 66 | } 67 | 68 | await Task.yield() 69 | } catch (let error) { 70 | print(error) 71 | updateRefreshStatus(collectionID: collection.id, refreshing: false) 72 | } 73 | } 74 | } 75 | 76 | print("---------------------Fetched--------------------------") 77 | 78 | self.updateRefreshStatus(collectionID: collection.id, refreshing: nil) 79 | await MainActor.run { 80 | UIApplication.shared.isIdleTimerDisabled = false 81 | } 82 | } 83 | 84 | } 85 | 86 | @MainActor 87 | public func updateRefreshStatus(collectionID: MangaCollection.ID, refreshing: Bool? = nil) { 88 | self.refreshStatus[collectionID] = refreshing 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Models/MangaChapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Chapter.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 22/12/2021. 6 | // 7 | 8 | import Foundation 9 | @preconcurrency import GRDB 10 | import MangaScraper 11 | 12 | public enum ChapterStatusHistory: String, Sendable { 13 | case all = "Update", read = "Read" 14 | } 15 | 16 | public enum ChapterStatusFilter: String, Sendable { 17 | case all, unread 18 | 19 | static func toggle(value: Self) -> Self { 20 | if value == .all { 21 | return .unread 22 | } 23 | else { 24 | return .all 25 | } 26 | } 27 | 28 | } 29 | 30 | public enum ChapterStatus: String, CaseIterable, Codable, DatabaseValueConvertible, Sendable { 31 | case unread, read 32 | 33 | func inverse() -> ChapterStatus { 34 | switch self { 35 | case .unread: 36 | return .read 37 | case .read: 38 | return .unread 39 | } 40 | } 41 | } 42 | 43 | public struct MangaChapter: Identifiable, Equatable, Codable, Sendable { 44 | public var id: String 45 | public var chapterId: String 46 | public var title: String 47 | public var dateSourceUpload: Date 48 | public var position: Int 49 | public var readAt: Date? 50 | public var status: ChapterStatus 51 | public var mangaId: UUID 52 | public var externalUrl: String? 53 | 54 | public var isUnread: Bool { 55 | return status != .read 56 | } 57 | 58 | public init(from data: SourceChapter, position: Int, mangaId: UUID, scraperId: UUID) { 59 | self.id = "\(scraperId)@@\(data.id)" 60 | self.chapterId = data.id 61 | self.title = data.name 62 | self.dateSourceUpload = data.dateUpload 63 | self.position = position 64 | self.mangaId = mangaId 65 | self.status = .unread 66 | self.externalUrl = data.externalUrl 67 | } 68 | } 69 | 70 | extension MangaChapter: FetchableRecord, PersistableRecord {} 71 | 72 | extension MangaChapter: TableRecord { 73 | public static let manga = belongsTo(Manga.self) 74 | public static let scraper = hasOne(Scraper.self, through: manga, using: Manga.scraper) 75 | 76 | public enum Columns { 77 | public static let id = Column(CodingKeys.id) 78 | public static let chapterId = Column(CodingKeys.chapterId) 79 | public static let title = Column(CodingKeys.title) 80 | public static let dateSourceUpload = Column(CodingKeys.dateSourceUpload) 81 | public static let position = Column(CodingKeys.position) 82 | public static let readAt = Column(CodingKeys.readAt) 83 | public static let status = Column(CodingKeys.status) 84 | public static let mangaId = Column(CodingKeys.mangaId) 85 | public static let externalUrl = Column(CodingKeys.externalUrl) 86 | } 87 | 88 | public static let databaseSelection: [SQLSelectable] = [ 89 | Columns.id, 90 | Columns.chapterId, 91 | Columns.title, 92 | Columns.dateSourceUpload, 93 | Columns.position, 94 | Columns.readAt, 95 | Columns.status, 96 | Columns.mangaId, 97 | Columns.externalUrl 98 | ] 99 | } 100 | 101 | public extension DerivableRequest where RowDecoder == MangaChapter { 102 | func forMangaId(_ mangaId: UUID) -> Self { 103 | filter(RowDecoder.Columns.mangaId == mangaId) 104 | } 105 | 106 | func forMangaId(_ mangaId: String, _ scraperId: UUID) -> Self { 107 | joining(required: RowDecoder.manga.forMangaId(mangaId, scraperId)) 108 | } 109 | 110 | func forChapterStatus(_ status: ChapterStatus) -> Self { 111 | filter(RowDecoder.Columns.status == status) 112 | } 113 | 114 | func orderHistoryAll() -> Self { 115 | order(RowDecoder.Columns.dateSourceUpload.desc, MangaChapter.Columns.mangaId, MangaChapter.Columns.position.asc) 116 | } 117 | 118 | func orderHistoryRead() -> Self { 119 | order(RowDecoder.Columns.readAt.desc, MangaChapter.Columns.mangaId, MangaChapter.Columns.position.asc) 120 | } 121 | 122 | func filter(_ status: ChapterStatusHistory) -> Self { 123 | guard let last30days = Calendar.current.date(byAdding: .day, value: -31, to: Date()) else { return self } 124 | let dc = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: last30days) 125 | let date = DatabaseDateComponents(dc, format: .YMD_HMSS) 126 | 127 | switch status { 128 | case .all: return filter(RowDecoder.Columns.dateSourceUpload >= date).orderHistoryAll() 129 | case .read: return filter(RowDecoder.Columns.readAt >= date).orderHistoryRead().onlyRead() 130 | } 131 | } 132 | 133 | func onlyRead() -> Self { 134 | filter(RowDecoder.Columns.status == ChapterStatus.read) 135 | } 136 | } 137 | 138 | public extension MangaChapter { 139 | 140 | static func markAllAs(newStatus: ChapterStatus, date: Date = .now, db: Database, mangaId: UUID) throws { 141 | return try db.execute(sql: """ 142 | UPDATE "mangaChapter" SET status = ?, "readAt" = ? WHERE status = ? AND "mangaId" = ? 143 | """, arguments: [newStatus, newStatus == .unread ? nil : date, newStatus.inverse(), mangaId]) 144 | } 145 | 146 | static func markChapterAs(newStatus: ChapterStatus, date: Date = .now, db: Database, chapterId: String) throws { 147 | return try db.execute(sql: """ 148 | UPDATE "mangaChapter" SET status = ?, "readAt" = ? WHERE status = ? AND "id" = ? 149 | """, arguments: [newStatus, newStatus == .unread ? nil : date, newStatus.inverse(), chapterId]) 150 | } 151 | 152 | static func updateFromSource(db: Database, manga: Manga, scraper: Scraper, data: SourceManga) throws { 153 | // Sometimes all chapters are deleted, I don't know why and it's impossible to reproduce in test 154 | guard !data.chapters.isEmpty else { 155 | print("Empty chapters, weird so abort to avoid losing read chapters") 156 | return 157 | } 158 | 159 | let oldChapters = try MangaChapter 160 | .all() 161 | .onlyRead() 162 | .forMangaId(manga.mangaId, scraper.id) 163 | .fetchAll(db) 164 | 165 | for info in data.chapters.enumerated() { 166 | var chapter = MangaChapter(from: info.element, position: info.offset, mangaId: manga.id, scraperId: manga.scraperId!) 167 | if let foundBackup = oldChapters.first(where: { $0.id == chapter.id }) { 168 | chapter.readAt = foundBackup.readAt 169 | chapter.status = .read 170 | chapter.externalUrl = info.element.externalUrl 171 | } 172 | 173 | try chapter.save(db) 174 | } 175 | 176 | // Clean chapter removed from source 177 | let dbChapters = try MangaChapter.all().forMangaId(manga.id).fetchAll(db) 178 | for dbChapter in dbChapters { 179 | if (data.chapters.first(where: { $0.id == dbChapter.chapterId }) != nil) { continue } 180 | try dbChapter.delete(db) 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Models/MangaCollection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 21/12/2021. 6 | // 7 | 8 | import Foundation 9 | @preconcurrency import GRDB 10 | 11 | public enum MangaCollectionFilter: String, Codable, Equatable, CaseIterable, DatabaseValueConvertible, Hashable, Sendable { 12 | case onlyUnReadChapter = "Only Unread Chapter", all = "All", completed = "Only Completed" 13 | 14 | public init(rawValue: String) { 15 | switch rawValue.lowercased() { 16 | case "only unread chapter": self = .onlyUnReadChapter 17 | 18 | case "all": self = .all 19 | 20 | case "only completed": self = .completed 21 | 22 | default: self = .all 23 | } 24 | } 25 | } 26 | 27 | public struct MangaCollectionOrder: Codable, Equatable, DatabaseValueConvertible, Hashable, Sendable { 28 | public enum Field: String, Codable, CaseIterable, DatabaseValueConvertible, Hashable, Sendable { 29 | case unreadChapters = "By unread chapter", lastUpdate = "By last update", title = "By title", chapterCount = "By chapter count" 30 | 31 | public init(rawValue: String) { 32 | switch rawValue.lowercased() { 33 | case "by unread chapter": fallthrough 34 | case "unreadchapters": self = .unreadChapters 35 | 36 | case "by last update": fallthrough 37 | case "lastupdate": self = .lastUpdate 38 | 39 | case "by title": fallthrough 40 | case "title": self = .title 41 | 42 | case "by chapter count": fallthrough 43 | case "chaptercount": self = .chapterCount 44 | 45 | default: self = .lastUpdate 46 | } 47 | } 48 | } 49 | 50 | public enum Direction: String, Codable, CaseIterable, DatabaseValueConvertible, Hashable, Sendable { 51 | case ASC = "Ascending", DESC = "Descending" 52 | 53 | public init(rawValue: String) { 54 | switch rawValue.lowercased() { 55 | case "ascending": fallthrough 56 | case "asc": self = .ASC 57 | 58 | case "descending": fallthrough 59 | case "desc": self = .DESC 60 | 61 | default: self = .ASC 62 | } 63 | } 64 | } 65 | 66 | public var field: Field = .lastUpdate 67 | public var direction: Direction = .DESC 68 | } 69 | 70 | public struct MangaCollection: Codable, Identifiable, Equatable, Hashable, Sendable { 71 | public var id: UUID 72 | public var name: String 73 | public var position: Int 74 | public var filter: MangaCollectionFilter = .all 75 | public var order: MangaCollectionOrder = .init() 76 | public var useList: Bool? = false 77 | 78 | public init(id: UUID, name: String, position: Int, filter: MangaCollectionFilter? = nil, order: MangaCollectionOrder? = nil, useList: Bool? = nil) { 79 | self.id = id 80 | self.name = name 81 | self.position = position 82 | if let filter = filter { 83 | self.filter = filter 84 | } 85 | if let order = order { 86 | self.order = order 87 | } 88 | if let useList = useList { 89 | self.useList = useList 90 | } 91 | } 92 | } 93 | 94 | extension MangaCollection: FetchableRecord, PersistableRecord {} 95 | 96 | extension MangaCollection: TableRecord { 97 | public static let mangas = hasMany(Manga.self) 98 | 99 | public enum Columns { 100 | public static let id = Column(CodingKeys.id) 101 | public static let name = Column(CodingKeys.name) 102 | public static let position = Column(CodingKeys.position) 103 | public static let filter = Column(CodingKeys.filter) 104 | public static let order = Column(CodingKeys.order) 105 | public static let useList = Column(CodingKeys.useList) 106 | } 107 | 108 | public static let databaseSelection: [SQLSelectable] = [ 109 | Columns.id, 110 | Columns.name, 111 | Columns.position, 112 | Columns.filter, 113 | Columns.order, 114 | Columns.useList 115 | ] 116 | } 117 | 118 | public extension DerivableRequest where RowDecoder == MangaCollection { 119 | func orderByPosition() -> Self { 120 | order( 121 | RowDecoder.Columns.position.ascNullsLast, 122 | RowDecoder.Columns.name.collating(.localizedCaseInsensitiveCompare).asc 123 | ) 124 | } 125 | } 126 | 127 | public extension MangaCollection { 128 | static func fetchOrCreateFromBackup(db: Database, backup: Self) throws -> MangaCollection { 129 | if let collection = try MangaCollection.fetchOne(db, id: backup.id) { 130 | return collection 131 | } 132 | 133 | return try MangaCollection(id: backup.id, name: backup.name, position: backup.position, filter: backup.filter, order: backup.order, useList: backup.useList).saved(db) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Models/Scraper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Source.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 21/12/2021. 6 | // 7 | 8 | import Foundation 9 | @preconcurrency import GRDB 10 | import MangaScraper 11 | 12 | public struct Scraper: Identifiable, Equatable, Codable, Hashable, Sendable { 13 | public var id: UUID 14 | public var name: String 15 | public var position: Int? 16 | public var isFavorite: Bool 17 | public var isActive: Bool 18 | 19 | public init(id: UUID, name: String, position: Int? = nil, isFavorite: Bool = false, isActive: Bool = false) { 20 | self.id = id 21 | self.name = name 22 | self.position = position 23 | self.isActive = isActive 24 | self.isFavorite = isFavorite 25 | } 26 | 27 | public init(from source: Source) { 28 | self.id = source.id 29 | self.name = source.name 30 | self.isActive = false 31 | self.isFavorite = false 32 | } 33 | 34 | public func asSource() -> Source? { 35 | return MangaScraperService.shared.getSource(sourceId: self.id) 36 | } 37 | } 38 | 39 | extension Scraper: FetchableRecord, PersistableRecord {} 40 | 41 | extension Scraper: TableRecord { 42 | public static let mangas = hasMany(Manga.self) 43 | 44 | public enum Columns: CaseIterable { 45 | public static let id = Column(CodingKeys.id) 46 | public static let name = Column(CodingKeys.name) 47 | public static let position = Column(CodingKeys.position) 48 | public static let isFavorite = Column(CodingKeys.isFavorite) 49 | public static let isActive = Column(CodingKeys.isActive) 50 | } 51 | 52 | public static let databaseSelection: [SQLSelectable] = [ 53 | Columns.id, 54 | Columns.name, 55 | Columns.position, 56 | Columns.isActive, 57 | Columns.isFavorite 58 | ] 59 | } 60 | 61 | public extension DerivableRequest where RowDecoder == Scraper { 62 | func onlyActive(_ bool: Bool = true) -> Self { 63 | filter(RowDecoder.Columns.isActive == bool) 64 | } 65 | 66 | func onlyFavorite(_ bool: Bool = true) -> Self { 67 | filter(RowDecoder.Columns.isFavorite == bool) 68 | } 69 | 70 | func orderByPosition() -> Self { 71 | order( 72 | RowDecoder.Columns.position.ascNullsLast, 73 | RowDecoder.Columns.name.collating(.localizedCaseInsensitiveCompare).asc 74 | ) 75 | } 76 | } 77 | 78 | public extension Scraper { 79 | static func fetchOne(_ db: Database, source: Source) throws -> Self { 80 | if let scraper = try Self.fetchOne(db, id: source.id) { return scraper } 81 | 82 | let source = Scraper(from: source) 83 | return try source.saved(db) 84 | } 85 | 86 | static func fetchOne(_ db: Database, sourceId: UUID) throws -> Self { 87 | if let scraper = try Self.fetchOne(db, id: sourceId) { return scraper } 88 | else { throw "Scraper not found" } 89 | } 90 | } 91 | 92 | 93 | public extension Scraper { 94 | static func fetchOrCreateFromBackup(db: Database, backup: Self) throws -> Scraper { 95 | if let collection = try Scraper.fetchOne(db, id: backup.id) { 96 | return collection 97 | } 98 | 99 | return try Scraper(id: backup.id, name: backup.name, position: backup.position, isFavorite: backup.isFavorite, isActive: backup.isActive).saved(db) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Persistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Persistence.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 17/08/2021. 6 | // 7 | import SwiftUI 8 | @preconcurrency import GRDB 9 | import GRDBQuery 10 | import OSLog 11 | import MangaScraper 12 | import Common 13 | 14 | /// AppDatabase lets the application access the database. 15 | /// 16 | /// It applies the pratices recommended at 17 | /// 18 | public struct AppDatabase { 19 | /// Creates an `AppDatabase`, and make sure the database schema is ready. 20 | public init(_ dbWriter: DatabaseWriter) throws { 21 | self.database = dbWriter 22 | try migrator.migrate(dbWriter) 23 | } 24 | 25 | /// Provides access to the database. 26 | /// 27 | /// Application can use a `DatabasePool`, while SwiftUI previews and tests 28 | /// can use a fast in-memory `DatabaseQueue`. 29 | /// 30 | /// See 31 | public let database: DatabaseWriter 32 | 33 | /// The DatabaseMigrator that defines the database schema. 34 | /// 35 | /// See 36 | private var migrator: DatabaseMigrator { 37 | var migrator = DatabaseMigrator() 38 | 39 | #if DEBUG 40 | // Speed up development by nuking the database when migrations change 41 | // See https://github.com/groue/GRDB.swift/blob/master/Documentation/Migrations.md#the-erasedatabaseonschemachange-option 42 | // migrator.eraseDatabaseOnSchemaChange = true 43 | #endif 44 | 45 | Logger.migration.info("Registering init_state migration") 46 | migrator.registerMigration("init_state") { db in 47 | try db.create(table: "mangaCollection") { t in 48 | t.column("id", .text).primaryKey(onConflict: .ignore, autoincrement: false) 49 | t.column("name", .text).notNull() 50 | t.column("position", .integer).notNull() 51 | t.column("filter", .text).notNull() 52 | t.column("order", .text).notNull() 53 | } 54 | 55 | try db.create(table: "scraper") { t in 56 | t.column("id", .text).primaryKey(onConflict: .ignore, autoincrement: false) 57 | t.column("name", .text).notNull().indexed() 58 | t.column("position", .integer) 59 | t.column("isFavorite", .boolean).notNull().defaults(to: false).indexed() 60 | t.column("isActive", .boolean).notNull().defaults(to: false).indexed() 61 | } 62 | 63 | try db.create(table: "manga") { t in 64 | t.column("id", .text).notNull().primaryKey() 65 | t.column("title", .text).notNull().indexed() 66 | t.column("cover", .text).notNull().defaults(to: URL(string: "https://via.placeholder.com/240x300")!) 67 | t.column("synopsis", .text).notNull().defaults(to: "No Synopsis") 68 | t.column("mangaId", .text).notNull().indexed() 69 | t.column("status", .text).notNull().indexed() 70 | t.column("type", .text).notNull().indexed() 71 | t.column("alternateTitles", .text).indexed() 72 | t.column("genres", .text).indexed() 73 | t.column("authors", .text).indexed() 74 | t.column("artists", .text).indexed() 75 | t.column("mangaCollectionId", .text).indexed().references("mangaCollection", onDelete: .cascade, onUpdate: .cascade) 76 | t.column("scraperId", .text).indexed().references("scraper", onDelete: .setNull, onUpdate: .cascade) 77 | t.uniqueKey(["scraperId", "mangaId"], onConflict: .replace) 78 | } 79 | 80 | try db.create(table: "mangaChapter") { t in 81 | t.column("id", .text).notNull().primaryKey() 82 | t.column("chapterId", .text).notNull() 83 | t.column("title", .text).notNull().defaults(to: "No Title") 84 | t.column("dateSourceUpload", .datetime).notNull().defaults(sql: "CURRENT_TIMESTAMP") 85 | t.column("position", .integer).notNull() 86 | t.column("readAt", .datetime) 87 | t.column("status", .text).notNull() 88 | t.column("mangaId", .integer).notNull().indexed().references("manga", onDelete: .cascade, onUpdate: .cascade) 89 | } 90 | } 91 | 92 | Logger.migration.info("Registering update_index migration") 93 | migrator.registerMigration("update_index") { db in 94 | try db.create(index: "mangaChapter_status", on: "mangaChapter", columns: ["status"]) 95 | try db.create(index: "mangaChapter_readAt", on: "mangaChapter", columns: ["readAt"]) 96 | try db.create(index: "mangaChapter_position", on: "mangaChapter", columns: ["position"]) 97 | try db.create(index: "mangaChapter_dateSourceUpload", on: "mangaChapter", columns: ["dateSourceUpload"]) 98 | } 99 | 100 | Logger.migration.info("Registering update_mangaChapter_table migration") 101 | migrator.registerMigration("update_mangaChapter_table") { db in 102 | try db.alter(table: "mangaChapter") { t in 103 | t.add(column: "externalUrl", .text) 104 | } 105 | } 106 | 107 | Logger.migration.info("Registering migration") 108 | migrator.registerMigration("add_useList_to_mangaCollection") { db in 109 | try db.alter(table: "mangaCollection") { t in 110 | t.add(column: "useList", .boolean).notNull().defaults(to: "false") 111 | } 112 | } 113 | 114 | return migrator 115 | } 116 | } 117 | 118 | public extension AppDatabase { 119 | func createUiDataIfEmpty() throws { 120 | try database.write { db in 121 | if try MangaCollection.all().isEmpty(db) { 122 | try createUITestMangaCollection(db) 123 | } 124 | 125 | if try Scraper.all().isEmpty(db) { 126 | try createUITestScraper(db) 127 | } 128 | } 129 | } 130 | 131 | static let uiTestMangaCollection = [ 132 | MangaCollection(id: UUID(), name: "Reading", position: 1), 133 | MangaCollection(id: UUID(), name: "To Read", position: 2), 134 | MangaCollection(id: UUID(), name: "Special", position: 3), 135 | MangaCollection(id: UUID(), name: "Done", position: 4), 136 | MangaCollection(id: UUID(), name: "Paper", position: 5), 137 | ] 138 | 139 | static let uiTestScraper = [ 140 | Scraper(id: UUID(), name: "Favorite", isFavorite: true, isActive: true), 141 | Scraper(id: UUID(), name: "Active", isFavorite: false, isActive: true), 142 | ] 143 | 144 | private func createUITestMangaCollection(_ db: Database) throws { 145 | try AppDatabase.uiTestMangaCollection.forEach { _ = try $0.inserted(db) } 146 | } 147 | 148 | private func createUITestScraper(_ db: Database) throws { 149 | try AppDatabase.uiTestMangaCollection.forEach { _ = try $0.inserted(db) } 150 | } 151 | } 152 | 153 | public extension AppDatabase { 154 | /// The database for the application 155 | nonisolated(unsafe) static let shared = makeShared() 156 | 157 | private static func makeShared() -> AppDatabase { 158 | do { 159 | let fileManager = FileManager() 160 | 161 | let folderURL = fileManager 162 | .containerURL(forSecurityApplicationGroupIdentifier: "group.tech.azsiaz.Dokusho")? 163 | .appendingPathComponent("database", isDirectory: true) 164 | 165 | guard let folderURL = folderURL else { throw "Folder not existing" } 166 | 167 | try fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true) 168 | 169 | let dbURL = folderURL.appendingPathComponent("db.sqlite") 170 | Logger.persistence.info("Db is \(dbURL.path)") 171 | 172 | var config = Configuration() 173 | #if DEBUG 174 | config.prepareDatabase { db in 175 | db.trace { print($0) } 176 | } 177 | #endif 178 | 179 | let dbPool = try DatabasePool(path: dbURL.path, configuration: config) 180 | 181 | return try AppDatabase(dbPool) 182 | } catch { 183 | // Replace this implementation with code to handle the error appropriately. 184 | // fatalError() causes the application to generate a crash log and terminate. 185 | // 186 | // Typical reasons for an error here include: 187 | // * The parent directory cannot be created, or disallows writing. 188 | // * The database is not accessible, due to permissions or data protection when the device is locked. 189 | // * The device is out of space. 190 | // * The database could not be migrated to its latest schema version. 191 | // Check the error message to determine what the actual problem was. 192 | fatalError("Unresolved error \(error)") 193 | } 194 | } 195 | 196 | /// Creates an empty database for SwiftUI previews 197 | static func makeEmpty() -> AppDatabase { 198 | return try! AppDatabase(DatabaseQueue()) 199 | } 200 | 201 | /// Creates a database full of data for SwiftUI previews 202 | static func uiTest() -> AppDatabase { 203 | let appDatabase = makeEmpty() 204 | try! appDatabase.createUiDataIfEmpty() 205 | 206 | return appDatabase 207 | } 208 | } 209 | 210 | public extension Query where Request.DatabaseContext == AppDatabase { 211 | /// Convenience initializer for requests that feed from `AppDatabase`. 212 | init(_ request: Request) { 213 | self.init(request, in: \.appDatabase) 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Requests/ChaptersHistoryRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChaptersHistoryRequest.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 23/12/2021. 6 | // 7 | 8 | import Combine 9 | import GRDB 10 | import GRDBQuery 11 | 12 | public struct ChaptersHistory: Decodable, FetchableRecord, Identifiable { 13 | public var id: String { chapter.id } 14 | public var chapter: MangaChapter 15 | public var manga: PartialManga 16 | public var scraper: Scraper 17 | } 18 | 19 | public struct ChaptersHistoryRequest: Queryable { 20 | public var filter: ChapterStatusHistory = .all 21 | public var searchTerm: String 22 | 23 | public init(filter: ChapterStatusHistory, searchTerm: String) { 24 | self.filter = filter 25 | self.searchTerm = searchTerm 26 | } 27 | 28 | public static var defaultValue: [ChaptersHistory] { [] } 29 | 30 | public func publisher(in database: AppDatabase) -> AnyPublisher<[ChaptersHistory], Error> { 31 | ValueObservation 32 | .tracking(fetchValue(_:)) 33 | .publisher(in: database.database, scheduling: .immediate) 34 | .eraseToAnyPublisher() 35 | } 36 | 37 | public func fetchValue(_ db: Database) throws -> [ChaptersHistory] { 38 | var request = MangaChapter 39 | .all() 40 | .including(required: MangaChapter.manga) 41 | .including(required: MangaChapter.scraper) 42 | .filter(filter) 43 | 44 | if !searchTerm.isEmpty { request = request.including(required: MangaChapter.manga.filterByName(searchTerm)) } 45 | 46 | return try ChaptersHistory.fetchAll(db, request) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Requests/DetailedMangaCollectionRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailedMangaCollectionRequest.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 22/12/2021 6 | // 7 | 8 | import Combine 9 | import GRDBQuery 10 | import GRDB 11 | import SwiftUI 12 | 13 | public struct DetailedMangaCollection: Decodable, FetchableRecord, Identifiable { 14 | public var mangaCollection: MangaCollection 15 | public var mangaCount: Int 16 | public var id: UUID { mangaCollection.id } 17 | } 18 | 19 | public struct DetailedMangaCollectionRequest: Queryable { 20 | public static var defaultValue: [DetailedMangaCollection] { [] } 21 | 22 | public init() {} 23 | 24 | public func publisher(in database: AppDatabase) -> AnyPublisher<[DetailedMangaCollection], Error> { 25 | ValueObservation 26 | .tracking(fetchValue(_:)) 27 | .publisher(in: database.database, scheduling: .immediate) 28 | .eraseToAnyPublisher() 29 | } 30 | 31 | public func fetchValue(_ db: Database) throws -> [DetailedMangaCollection] { 32 | let request = MangaCollection 33 | .annotated(with: MangaCollection.mangas.count) 34 | .group(MangaCollection.Columns.id) 35 | .orderByPosition() 36 | 37 | return try DetailedMangaCollection 38 | .fetchAll(db, request) 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Requests/DetailedMangaInList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailedMangaInCollectionsRequest.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 22/12/2021. 6 | // 7 | 8 | import GRDB 9 | import GRDBQuery 10 | import Combine 11 | import Foundation 12 | 13 | public enum DetailedMangaRequestType: Equatable { 14 | case genre(genre: String) 15 | case scraper(scraper: Scraper) 16 | case collection(collectionId: MangaCollection.ID) 17 | } 18 | 19 | public struct DetailedMangaInList: Identifiable, Hashable, FetchableRecord, Decodable { 20 | public var id: UUID { manga.id } 21 | public var manga: PartialManga 22 | public var scraper: Scraper 23 | public var unreadChapterCount: Int 24 | public var readChapterCount: Int 25 | public var chapterCount: Int 26 | public var lastUpdate: Date? 27 | } 28 | 29 | public struct DetailedMangaInListRequest: Queryable { 30 | public static var defaultValue: [DetailedMangaInList] { [] } 31 | 32 | public var requestType: DetailedMangaRequestType 33 | public var searchTerm: String 34 | 35 | public init(requestType: DetailedMangaRequestType, searchTerm: String = "") { 36 | self.requestType = requestType 37 | self.searchTerm = searchTerm 38 | } 39 | 40 | public func publisher(in database: AppDatabase) -> AnyPublisher<[DetailedMangaInList], Error> { 41 | ValueObservation 42 | .tracking(fetchValue(_:)) 43 | .publisher(in: database.database, scheduling: .immediate) 44 | .eraseToAnyPublisher() 45 | } 46 | 47 | public func fetchValue(_ db: Database) throws -> [DetailedMangaInList] { 48 | let unreadChapterCount = "DISTINCT \"mangaChapter\".\"rowid\") FILTER (WHERE mangaChapter.status = 'unread'" 49 | let readChapterCount = "DISTINCT \"mangaChapter\".\"rowid\") FILTER (WHERE mangaChapter.status = 'read'" 50 | let chapterCount = "DISTINCT \"mangaChapter\".\"rowid\"" 51 | 52 | var request = Manga 53 | .select([ 54 | Manga.Columns.id, 55 | Manga.Columns.mangaId, 56 | Manga.Columns.title, 57 | Manga.Columns.scraperId, 58 | Manga.Columns.cover, 59 | count(SQL(sql: unreadChapterCount)).forKey("unreadChapterCount"), 60 | count(SQL(sql: readChapterCount)).forKey("readChapterCount"), 61 | count(SQL(sql: chapterCount)).forKey("chapterCount"), 62 | max(SQL(sql: "\"mangaChapter\".\"dateSourceUpload\"")).forKey("lastUpdate") 63 | ]) 64 | .joining(optional: Manga.chapters) 65 | .including(required: Manga.scraper) 66 | .group(Manga.Columns.id) 67 | 68 | if !searchTerm.isEmpty { request = request.filterByName(searchTerm) } 69 | 70 | switch requestType { 71 | case .genre(let genre): 72 | request = request.orderByTitle().filterByGenre(genre).isInCollection() 73 | case .scraper(let scraper): 74 | request = request.orderByTitle().whereSource(scraper.id).isInCollection() 75 | case .collection(let collectionId): 76 | let collection = try? MangaCollection.all().filter(id: collectionId).fetchOne(db) 77 | 78 | request = request.forCollectionId(collectionId) 79 | 80 | if let filter = collection?.filter { 81 | switch filter { 82 | case .all: break 83 | case .onlyUnReadChapter: request = request.having(sql: "unreadChapterCount > 0") 84 | case .completed: request = request.forMangaStatus(.complete) 85 | } 86 | } 87 | 88 | 89 | if let order = collection?.order { 90 | switch order.field { 91 | case .unreadChapters: request = request.order(sql: "unreadChapterCount \(order.direction)") 92 | case .title: request = request.orderByTitle(direction: order.direction) 93 | case .lastUpdate: request = request.order(sql: "mangaChapter.dateSourceUpload \(order.direction)") 94 | case .chapterCount: request = request.order(sql: "chapterCount \(order.direction)") 95 | } 96 | } 97 | } 98 | 99 | return try DetailedMangaInList.fetchAll(db, request) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Requests/DistinctMangaGenreRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DistinctMangaGenreRequest.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 23/12/2021. 6 | // 7 | 8 | import Combine 9 | import GRDBQuery 10 | import GRDB 11 | import SwiftUI 12 | 13 | public struct GenreWithMangaCount: Decodable, FetchableRecord, Identifiable { 14 | public var id: String { genre } 15 | public var genre: String 16 | public var mangaCount: Int 17 | } 18 | 19 | public struct DistinctMangaGenreRequest: Queryable { 20 | public static var defaultValue: [GenreWithMangaCount] { [] } 21 | 22 | public init() {} 23 | 24 | public func publisher(in database: AppDatabase) -> AnyPublisher<[GenreWithMangaCount], Error> { 25 | ValueObservation 26 | .tracking(fetchValue(_:)) 27 | .publisher(in: database.database, scheduling: .immediate) 28 | .eraseToAnyPublisher() 29 | } 30 | 31 | public func fetchValue(_ db: Database) throws -> [GenreWithMangaCount] { 32 | return try GenreWithMangaCount.fetchAll(db, sql: """ 33 | SELECT DISTINCT(t2.value) as genre, COUNT(DISTINCT(t1.rowid)) as mangaCount 34 | FROM manga AS t1 35 | JOIN json_each((SELECT genres FROM manga WHERE id = t1.id)) AS t2 36 | WHERE t1."mangaCollectionId" IS NOT NULL 37 | GROUP BY t2.value; 38 | """) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Requests/MangaChaptersRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaChaptersRequest.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 23/12/2021. 6 | // 7 | 8 | import Combine 9 | import GRDB 10 | import GRDBQuery 11 | 12 | public struct MangaChaptersRequest: Queryable { 13 | public enum Order { 14 | case ASC, DESC 15 | } 16 | 17 | public var manga: Manga 18 | public var ascendingOrder = true 19 | public var filterAll = true 20 | 21 | public init(manga: Manga) { 22 | self.manga = manga 23 | } 24 | 25 | public static var defaultValue: [MangaChapter] { [] } 26 | 27 | public func publisher(in database: AppDatabase) -> AnyPublisher<[MangaChapter], Error> { 28 | ValueObservation 29 | .tracking(fetchValue(_:)) 30 | .publisher(in: database.database, scheduling: .immediate) 31 | .eraseToAnyPublisher() 32 | } 33 | 34 | public func fetchValue(_ db: Database) throws -> [MangaChapter] { 35 | var request = MangaChapter 36 | .all() 37 | .forMangaId(manga.id) 38 | 39 | if !filterAll { 40 | request = request.filter(MangaChapter.Columns.readAt == nil) 41 | } 42 | 43 | switch ascendingOrder { 44 | case true: request = request.order(MangaChapter.Columns.position.asc) 45 | case false: request = request.order(MangaChapter.Columns.position.desc) 46 | } 47 | 48 | return try request.fetchAll(db) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Requests/MangaCollectionRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExploreSourceViewRequest.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 22/12/2021. 6 | // 7 | 8 | import Combine 9 | import GRDBQuery 10 | import GRDB 11 | import SwiftUI 12 | 13 | public struct MangaCollectionRequest: Queryable { 14 | public static var defaultValue: [MangaCollection] { [] } 15 | 16 | public init() {} 17 | 18 | public func publisher(in database: AppDatabase) -> AnyPublisher<[MangaCollection], Error> { 19 | ValueObservation 20 | .tracking(MangaCollection.all().orderByPosition().fetchAll(_:)) 21 | .publisher(in: database.database, scheduling: .immediate) 22 | .eraseToAnyPublisher() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Requests/MangaDetailRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OneMangaCollectionRequest.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 20/04/2022. 6 | // 7 | 8 | @preconcurrency import GRDBQuery 9 | @preconcurrency import GRDB 10 | import Foundation 11 | 12 | public struct MangaDetailRequest: Queryable, Sendable { 13 | public static var defaultValue: MangaWithDetail? { nil } 14 | 15 | public var mangaId: String 16 | public var scraper: Scraper 17 | 18 | public init(mangaId: String, scraper: Scraper) { 19 | self.mangaId = mangaId 20 | self.scraper = scraper 21 | } 22 | 23 | public func publisher(in database: AppDatabase) -> DatabasePublishers.Value { 24 | ValueObservation 25 | .tracking(fetchValue(_:)) 26 | .publisher(in: database.database, scheduling: .immediate) 27 | } 28 | 29 | public func fetchValue(_ db: Database) throws -> MangaWithDetail? { 30 | guard let manga = try Manga.fetchMangaWithDetail(for: mangaId, in: scraper.id, db) else { 31 | Task { [self] in 32 | guard let source = scraper.asSource() else { throw "Source Not found" } 33 | let sourceManga = try await source.fetchMangaDetail(id: mangaId) 34 | 35 | try _ = await AppDatabase.shared.database.write { [self] db in 36 | try Manga.updateFromSource(db: db, scraper: self.scraper, data: sourceManga) 37 | } 38 | } 39 | 40 | return nil 41 | } 42 | 43 | return manga 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Requests/MangaInCollectionsRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaInCollectionsRequest.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 22/12/2021. 6 | // 7 | 8 | import Combine 9 | import GRDBQuery 10 | import GRDB 11 | import SwiftUI 12 | 13 | public struct MangaInCollection: Decodable, FetchableRecord { 14 | public var mangaId: String 15 | public var collectionName: String 16 | } 17 | 18 | public struct MangaInCollectionsRequest: Queryable { 19 | public static var defaultValue: [MangaInCollection] { [] } 20 | 21 | public let srcId: UUID 22 | 23 | public init(srcId: UUID) { 24 | self.srcId = srcId 25 | } 26 | 27 | public func publisher(in database: AppDatabase) -> AnyPublisher<[MangaInCollection], Error> { 28 | ValueObservation 29 | .tracking(fetchValue(_:)) 30 | .publisher(in: database.database, scheduling: .immediate) 31 | .eraseToAnyPublisher() 32 | } 33 | 34 | public func fetchValue(_ db: Database) throws -> [MangaInCollection] { 35 | return try Manga 36 | .select([Manga.Columns.mangaId]) 37 | .annotated(withRequired: Manga.mangaCollection.select(MangaCollection.Columns.name.forKey("collectionName"))) 38 | .whereSource(srcId) 39 | .asRequest(of: MangaInCollection.self) 40 | .fetchAll(db) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Requests/OneMangaCollectionRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OneMangaCollectionRequest.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 22/12/2021. 6 | // 7 | 8 | import GRDBQuery 9 | import GRDB 10 | import Foundation 11 | 12 | public struct OneMangaCollectionRequest: Queryable { 13 | public static var defaultValue: MangaCollection? { nil } 14 | 15 | public var collectionId: UUID 16 | 17 | public init(collectionId: UUID) { 18 | self.collectionId = collectionId 19 | } 20 | 21 | public func publisher(in database: AppDatabase) -> DatabasePublishers.Value { 22 | ValueObservation 23 | .tracking(MangaCollection.filter(id: collectionId).fetchOne(_:)) 24 | .publisher(in: database.database, scheduling: .immediate) 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Requests/ScraperRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExploreTabViewRequest.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 21/12/2021. 6 | // 7 | 8 | import Combine 9 | import GRDBQuery 10 | import GRDB 11 | import SwiftUI 12 | 13 | public struct ScraperRequest: Queryable { 14 | public enum Relevant { 15 | case onlyActive, onlyFavorite 16 | } 17 | 18 | public var type: Relevant 19 | 20 | public static var defaultValue: [Scraper] { [] } 21 | 22 | public init(type: Relevant) { 23 | self.type = type 24 | } 25 | 26 | public func publisher(in database: AppDatabase) -> AnyPublisher<[Scraper], Error> { 27 | ValueObservation 28 | .tracking(fetchValue(_:)) 29 | .publisher(in: AppDatabase.shared.database, scheduling: .immediate) 30 | .eraseToAnyPublisher() 31 | } 32 | 33 | public func fetchValue(_ db: Database) throws -> [Scraper] { 34 | let request = Scraper.all() 35 | 36 | switch type { 37 | case .onlyActive: 38 | return try request.onlyActive().onlyFavorite(false).orderByPosition().fetchAll(db) 39 | case .onlyFavorite: 40 | return try request.onlyFavorite().onlyActive().orderByPosition().fetchAll(db) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Features/Sources/DataKit/Requests/ScraperWithMangaInCollection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScraperWithMangaInCollection.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 23/12/2021. 6 | // 7 | 8 | import Combine 9 | import GRDBQuery 10 | import GRDB 11 | import SwiftUI 12 | 13 | public struct ScraperWithMangaCount: Codable, FetchableRecord, Identifiable { 14 | public var id: UUID { scraper.id } 15 | public var scraper: Scraper 16 | public var mangaCount: Int 17 | } 18 | 19 | public struct ScraperWithMangaInCollection: Queryable { 20 | public static var defaultValue: [ScraperWithMangaCount] { [] } 21 | 22 | public init() {} 23 | 24 | public func publisher(in database: AppDatabase) -> AnyPublisher<[ScraperWithMangaCount], Error> { 25 | ValueObservation 26 | .tracking(fetchValue(_:)) 27 | .publisher(in: database.database, scheduling: .immediate) 28 | .eraseToAnyPublisher() 29 | } 30 | 31 | public func fetchValue(_ db: Database) throws -> [ScraperWithMangaCount] { 32 | let request = Scraper 33 | .select(Scraper.databaseSelection + [count(SQL(sql: "DISTINCT manga.rowid")).forKey("mangaCount")]) 34 | .joining(required: Scraper.mangas.isInCollection()) 35 | .group(Scraper.Columns.id) 36 | .orderByPosition() 37 | 38 | return try ScraperWithMangaCount.fetchAll(db, request) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Features/Sources/DynamicCollection/MangaForSourcePage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaForSourcePage.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 04/10/2021. 6 | // 7 | 8 | import SwiftUI 9 | import MangaScraper 10 | import GRDBQuery 11 | import DataKit 12 | import SharedUI 13 | import MangaDetail 14 | 15 | public struct MangaForSourcePage: View { 16 | @Query var list: [DetailedMangaInList] 17 | 18 | var scraper: Scraper 19 | var columns: [GridItem] = [GridItem(.adaptive(minimum: 130, maximum: 130))] 20 | 21 | public init(scraper: Scraper) { 22 | self.scraper = scraper 23 | _list = Query(DetailedMangaInListRequest(requestType: .scraper(scraper: scraper))) 24 | } 25 | 26 | public var body: some View { 27 | ScrollView { 28 | MangaList(mangas: list) { data in 29 | NavigationLink(destination: MangaDetail(mangaId: data.manga.mangaId, scraper: data.scraper)) { 30 | MangaCard(title: data.manga.title, imageUrl: data.manga.cover.absoluteString, chapterCount: data.unreadChapterCount) 31 | .mangaCardFrame() 32 | } 33 | .buttonStyle(.plain) 34 | } 35 | .navigationTitle("\(scraper.name) (\(list.count))") 36 | .navigationBarTitleDisplayMode(.automatic) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Features/Sources/DynamicCollection/MangaInCollectionForGenre.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaInCollectionForGenre.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 04/10/2021. 6 | // 7 | 8 | import SwiftUI 9 | import GRDBQuery 10 | import DataKit 11 | import SharedUI 12 | import MangaDetail 13 | 14 | public struct MangaInCollectionForGenre: View { 15 | @Query var list: [DetailedMangaInList] 16 | 17 | @State var selectedManga: DetailedMangaInList? 18 | 19 | var inModal: Bool 20 | var genre: String 21 | 22 | public init(genre: String, inModal: Bool = true) { 23 | self.inModal = inModal 24 | self.genre = genre 25 | _list = Query(DetailedMangaInListRequest(requestType: .genre(genre: genre))) 26 | } 27 | 28 | public var body: some View { 29 | if inModal { 30 | NavigationView { 31 | content 32 | } 33 | } else { 34 | content 35 | } 36 | } 37 | 38 | @ViewBuilder 39 | var content: some View { 40 | ScrollView { 41 | MangaList(mangas: list) { data in 42 | NavigationLink(destination: MangaDetail(mangaId: data.manga.mangaId, scraper: data.scraper)) { 43 | MangaCard(title: data.manga.title, imageUrl: data.manga.cover.absoluteString, chapterCount: data.unreadChapterCount) 44 | .mangaCardFrame() 45 | } 46 | .buttonStyle(.plain) 47 | } 48 | } 49 | .navigationTitle("\(genre) (\(list.count))") 50 | .navigationBarTitleDisplayMode(.automatic) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Features/Sources/ExploreTab/ExploreTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 13/08/2021. 6 | // 7 | 8 | import SwiftUI 9 | import MangaScraper 10 | import GRDB 11 | import GRDBQuery 12 | import Combine 13 | import DataKit 14 | import SharedUI 15 | 16 | public struct ExploreTabView: View { 17 | @Query(ScraperRequest(type: .onlyFavorite)) var favoriteScrapers 18 | @Query(ScraperRequest(type: .onlyActive)) var activeScrapers 19 | 20 | @StateObject var vm = ExploreTabVM() 21 | 22 | public init() {} 23 | 24 | public var body: some View { 25 | NavigationStack { 26 | List { 27 | if favoriteScrapers.count >= 1 { 28 | Section("Favorite") { 29 | ForEach(favoriteScrapers) { scraper in 30 | FavoriteSourceRowView(scraper: scraper) 31 | .id(scraper.id) 32 | } 33 | .onMove(perform: { vm.onMove(scrapers: favoriteScrapers, offsets: $0, position: $1) }) 34 | } 35 | } 36 | 37 | if activeScrapers.count >= 1 { 38 | Section("Active") { 39 | ForEach(activeScrapers) { scraper in 40 | ActiveSourceRowView(scraper: scraper) 41 | .id(scraper.id) 42 | } 43 | .onMove(perform: { vm.onMove(scrapers: activeScrapers, offsets: $0, position: $1) }) 44 | } 45 | } 46 | 47 | if vm.onlyGetThirdPartyScraper(favorite: favoriteScrapers, active: activeScrapers).count >= 1 { 48 | Section("All Sources") { 49 | ForEach(vm.onlyGetThirdPartyScraper(favorite: favoriteScrapers, active: activeScrapers), id: \.id) { source in 50 | OtherSourceRowView(source: source) 51 | .id(source.id) 52 | } 53 | } 54 | } 55 | } 56 | .toolbar { 57 | ToolbarItemGroup(placement: .navigationBarTrailing) { 58 | EditButton() 59 | } 60 | 61 | ToolbarItemGroup(placement: .navigationBarLeading) { 62 | Button(action: { vm.showSourceMangaSearchModal.toggle() }) { 63 | Image(systemName: "magnifyingglass") 64 | } 65 | } 66 | 67 | } 68 | .navigationTitle("Explore Source") 69 | } 70 | .sheet(isPresented: $vm.showSourceMangaSearchModal) { 71 | SearchSourceListScreen(scrapers: favoriteScrapers+activeScrapers) 72 | } 73 | } 74 | 75 | @ViewBuilder 76 | func OtherSourceRowView(source: Source) -> some View { 77 | SourceRow(src: source) 78 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { 79 | Button(action: { vm.toogleActive(source: source) }) { 80 | Label("Activate", systemImage: "checkmark") 81 | }.tint(.purple) 82 | } 83 | } 84 | 85 | @ViewBuilder 86 | func ActiveSourceRowView(scraper: Scraper) -> some View { 87 | let source = scraper.asSource()! 88 | 89 | ScraperRow(scraper: scraper) 90 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { 91 | Button(action: { vm.toogleActive(source: source) }) { 92 | Label("Deactivate", systemImage: "xmark") 93 | }.tint(.purple) 94 | } 95 | .swipeActions(edge: .leading, allowsFullSwipe: true) { 96 | Button(action: { vm.toogleFavorite(source: source) }) { 97 | Label("Favorite", systemImage: "hand.thumbsup") 98 | }.tint(.blue) 99 | } 100 | } 101 | 102 | @ViewBuilder 103 | func FavoriteSourceRowView(scraper: Scraper) -> some View { 104 | let source = scraper.asSource()! 105 | 106 | ScraperRow(scraper: scraper) 107 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { 108 | Button(action: { vm.toogleActive(source: source) }) { 109 | Label("Deactivate", systemImage: "xmark") 110 | }.tint(.purple) 111 | } 112 | .swipeActions(edge: .leading, allowsFullSwipe: true) { 113 | Button(action: { vm.toogleFavorite(source: source) }) { 114 | Label("Unfavorite", systemImage: "hand.thumbsdown") 115 | }.tint(.blue) 116 | } 117 | } 118 | 119 | @ViewBuilder 120 | func SourceRow(src: Source) -> some View { 121 | HStack { 122 | RemoteImageCacheView(url: src.icon, contentMode: .aspectFit) 123 | .frame(width: 32, height: 32) 124 | .padding(.trailing) 125 | 126 | VStack(alignment: .leading) { 127 | Text(src.name) 128 | Text(src.lang.rawValue) 129 | } 130 | .padding(.leading, 8) 131 | } 132 | .padding(.vertical) 133 | } 134 | 135 | @ViewBuilder 136 | func ScraperRow(scraper: Scraper) -> some View { 137 | let src = scraper.asSource()! 138 | NavigationLink(destination: ExploreSourceView(scraper: scraper)) { 139 | SourceRow(src: src) 140 | } 141 | } 142 | } 143 | 144 | //struct ExploreTabView_Previews: PreviewProvider { 145 | // static var previews: some View { 146 | // ExploreTabView() 147 | // .environment(\.appDatabase, .uiTest()) 148 | // } 149 | //} 150 | -------------------------------------------------------------------------------- /Features/Sources/ExploreTab/Screens/ExploreSourceView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExploreSourceView.swift 3 | // ExploreSourceView 4 | // 5 | // Created by Stephan Deumier on 14/08/2021. 6 | // 7 | 8 | import SwiftUI 9 | import MangaScraper 10 | import GRDBQuery 11 | import DataKit 12 | import SharedUI 13 | import MangaDetail 14 | import Collections 15 | 16 | public struct ExploreSourceView: View { 17 | @Environment(\.appDatabase) var database 18 | 19 | @Query var mangasInCollection: [MangaInCollection] 20 | @Query(MangaCollectionRequest()) var collections 21 | 22 | @State private var nextPage = 1 23 | @State private var isLoading = false 24 | @State private var initialized = false 25 | 26 | @State var mangas = OrderedSet() 27 | @State var error = false 28 | @State var type: SourceFetchType = .latest 29 | @State var selectedManga: SourceSmallManga? 30 | @State var fromSegment: Bool = false 31 | 32 | var scraper: Scraper 33 | 34 | public init(scraper: Scraper) { 35 | self.scraper = scraper 36 | _mangasInCollection = Query(MangaInCollectionsRequest(srcId: scraper.id)) 37 | } 38 | 39 | public var body: some View { 40 | ScrollView { 41 | switch(error, fromSegment, mangas.isEmpty) { 42 | case (true, _, true): ErrorBlock() 43 | case (true, _, false): ErrorWithMangaInListBlock() 44 | case (false, true, _): LoadingBlock() 45 | case (_, _, true): LoadingBlock() 46 | case (false, _, _): MangaListBlock() 47 | } 48 | } 49 | .refreshable { await fetchList(clean: true) } 50 | .toolbar { ToolbarItem(placement: .principal) { Header() } } 51 | .navigationTitle(getTitle()) 52 | .task { await initView() } 53 | .onChange(of: type) { _, _ in Task { await fetchList(clean: true, typeChange: true) } } 54 | } 55 | 56 | @ViewBuilder 57 | func ErrorWithMangaInListBlock() -> some View { 58 | Group { 59 | MangaListBlock() 60 | ErrorBlock() 61 | } 62 | } 63 | 64 | @ViewBuilder 65 | func MangaListBlock() -> some View { 66 | MangaList(mangas: mangas) { manga in 67 | NavigationLink(destination: MangaDetail(mangaId: manga.id, scraper: scraper)) { 68 | let found = mangasInCollection.first { $0.mangaId == manga.id } 69 | MangaCard(title: manga.title, imageUrl: manga.thumbnailUrl, collectionName: found?.collectionName ?? "") 70 | .mangaCardFrame() 71 | .contextMenu { ContextMenu(manga: manga) } 72 | .task { await fetchMoreIfPossible(for: manga) } 73 | } 74 | .buttonStyle(.plain) 75 | } 76 | } 77 | 78 | @ViewBuilder 79 | func LoadingBlock() -> some View { 80 | ProgressView() 81 | .progressViewStyle(.circular) 82 | .frame(maxWidth: .infinity) 83 | .scaleEffect(1.5) 84 | .padding(.bottom, 10) 85 | } 86 | 87 | @ViewBuilder 88 | func ErrorBlock() -> some View { 89 | VStack { 90 | Text("Something weird happened, try again") 91 | AsyncButton(action: { await fetchList(clean: true) }) { 92 | Image(systemName: "arrow.clockwise") 93 | } 94 | } 95 | } 96 | 97 | @ViewBuilder 98 | func Header() -> some View { 99 | Picker("Order", selection: $type) { 100 | ForEach(SourceFetchType.allCases) { type in 101 | Text(type.rawValue).tag(type) 102 | } 103 | } 104 | .pickerStyle(.segmented) 105 | .frame(maxWidth: 160) 106 | } 107 | 108 | @ViewBuilder 109 | func ContextMenu(manga: SourceSmallManga) -> some View { 110 | ForEach(collections) { collection in 111 | AsyncButton(action: { await addToCollection(smallManga: manga, collection: collection) }) { 112 | Text("Add to \(collection.name)") 113 | } 114 | } 115 | } 116 | 117 | @MainActor 118 | func fetchList(clean: Bool = false, typeChange: Bool = false) async { 119 | guard isLoading == false else { return } 120 | 121 | defer { 122 | fromSegment = false 123 | isLoading = false 124 | } 125 | 126 | if clean { 127 | nextPage = 1 128 | if typeChange { 129 | fromSegment = true 130 | error = false 131 | } 132 | } else { 133 | self.isLoading = true 134 | self.error = false 135 | } 136 | 137 | do { 138 | let newManga = try await type == .latest ? scraper.asSource()?.fetchLatestUpdates(page: nextPage) : scraper.asSource()?.fetchPopularManga(page: nextPage) 139 | 140 | withAnimation { 141 | if clean { self.mangas = OrderedSet(newManga!.mangas) } 142 | else { self.mangas.append(contentsOf: newManga!.mangas) } 143 | 144 | self.nextPage += 1 145 | } 146 | } catch { 147 | withAnimation { 148 | self.error = true 149 | } 150 | } 151 | } 152 | 153 | @MainActor 154 | func initView() async { 155 | if !initialized { 156 | await fetchList() 157 | 158 | withAnimation { 159 | self.initialized = true 160 | } 161 | } 162 | } 163 | 164 | func fetchMoreIfPossible(for manga: SourceSmallManga) async { 165 | if mangas.last == manga { 166 | return await fetchList() 167 | } 168 | } 169 | 170 | func getTitle() -> String { 171 | return "\(scraper.name) - \(type.rawValue)" 172 | } 173 | 174 | func addToCollection(smallManga: SourceSmallManga, collection: MangaCollection) async { 175 | guard let sourceManga = try? await scraper.asSource()?.fetchMangaDetail(id: smallManga.id) else { return } 176 | 177 | do { 178 | try await database.database.write { db -> Void in 179 | guard var manga = try Manga.all().forMangaId(smallManga.id, scraper.id).fetchOne(db) else { 180 | var manga = try Manga.updateFromSource(db: db, scraper: scraper, data: sourceManga) 181 | try manga.updateChanges(db) { 182 | $0.mangaCollectionId = collection.id 183 | } 184 | return 185 | } 186 | 187 | try manga.updateChanges(db) { 188 | $0.mangaCollectionId = collection.id 189 | } 190 | } 191 | } catch(let err) { 192 | print(err) 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /Features/Sources/ExploreTab/Screens/SearchSourceListScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchSourceList.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 21/04/2022. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Combine 11 | import GRDBQuery 12 | import MangaScraper 13 | import DataKit 14 | import SharedUI 15 | import MangaDetail 16 | 17 | public struct SearchSourceListScreen: View { 18 | @Query(MangaCollectionRequest()) var collections 19 | 20 | @State var searchText: String = "" 21 | @State var isSearchFocused: Bool = true 22 | 23 | var scrapers: [Scraper] 24 | 25 | public init(scrapers: [Scraper]) { 26 | self.scrapers = scrapers 27 | } 28 | 29 | public var body: some View { 30 | ScrollView(.vertical, showsIndicators: false) { 31 | DebouncedSearchBar(debouncedText: $searchText, isFocused: $isSearchFocused) 32 | .padding(.top, 10) 33 | .padding(.horizontal, 10) 34 | ForEach(scrapers) { scraper in 35 | ScraperSearch(scraper: scraper, textToSearch: $searchText, collections: collections) 36 | } 37 | } 38 | .padding(.top, 5) 39 | } 40 | } 41 | 42 | public struct ScraperSearch: View { 43 | @Query var mangasInCollection: [MangaInCollection] 44 | 45 | @StateObject var vm: SearchScraperVM 46 | @Binding var textToSearch: String 47 | 48 | var collections: [MangaCollection] 49 | 50 | public init(scraper: Scraper, textToSearch: Binding, collections: [MangaCollection]) { 51 | self.collections = collections 52 | _textToSearch = textToSearch 53 | _vm = .init(wrappedValue: .init(scraper: scraper)) 54 | _mangasInCollection = Query(MangaInCollectionsRequest(srcId: scraper.id)) 55 | } 56 | 57 | public var body: some View { 58 | VStack(alignment: .leading) { 59 | if textToSearch.isEmpty { 60 | EmptyView() 61 | } else { 62 | HStack { 63 | Text(vm.scraper.name) 64 | .padding(.top, 15) 65 | .padding(.leading, 15) 66 | Spacer() 67 | } 68 | Group { 69 | if vm.isLoading && vm.mangas.isEmpty { 70 | ProgressView() 71 | .padding(.leading, 25) 72 | } else if !vm.isLoading && vm.mangas.isEmpty { 73 | Text("No Results founds") 74 | .padding(.leading, 15) 75 | } 76 | else { 77 | SearchResult() 78 | } 79 | } 80 | .frame(height: !vm.isLoading && vm.mangas.isEmpty ? 50 : 180) 81 | } 82 | } 83 | .padding(.bottom, 10) 84 | .onChange(of: textToSearch) { _, text in 85 | Task { 86 | await vm.fetchData(textToSearch: textToSearch) 87 | } 88 | } 89 | .sheet(item: $vm.selectedManga) { manga in 90 | NavigationView { 91 | MangaDetail(mangaId: manga.id, scraper: vm.scraper) 92 | } 93 | } 94 | } 95 | 96 | @ViewBuilder 97 | func SearchResult() -> some View { 98 | ScrollView(.horizontal, showsIndicators: false) { 99 | LazyHStack { 100 | ForEach(vm.mangas) { manga in 101 | let found = mangasInCollection.first { $0.mangaId == manga.id } 102 | MangaCard(title: manga.title, imageUrl: manga.thumbnailUrl, collectionName: found?.collectionName ?? "") 103 | .mangaCardFrame() 104 | .contextMenu { ContextMenu(manga: manga) } 105 | .task { await self.vm.fetchMoreIfPossible(for: manga) } 106 | .onTapGesture { vm.selectedManga = manga } 107 | .padding(.trailing, vm.mangas.last == manga ? 15 : 0) 108 | .padding(.leading, vm.mangas.first == manga ? 15 : 0) 109 | } 110 | 111 | if vm.isLoading && !vm.mangas.isEmpty { 112 | ProgressView() 113 | } 114 | } 115 | } 116 | } 117 | 118 | @ViewBuilder 119 | func ContextMenu(manga: SourceSmallManga) -> some View { 120 | ForEach(collections) { collection in 121 | Button(action: { 122 | Task { 123 | await vm.addToCollection(smallManga: manga, collection: collection) 124 | } 125 | }) { 126 | Text("Add to \(collection.name)") 127 | } 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /Features/Sources/ExploreTab/ViewModels/ExploreTabVM.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 15/04/2022. 6 | // 7 | 8 | import Foundation 9 | import MangaScraper 10 | import DataKit 11 | 12 | class ExploreTabVM: ObservableObject { 13 | var database = AppDatabase.shared.database 14 | 15 | @Published var showSourceMangaSearchModal = false 16 | 17 | func onlyGetThirdPartyScraper(favorite: [Scraper], active: [Scraper]) -> [Source] { 18 | return MangaScraperService.shared.list 19 | .filter { src in return !active.contains(where: { scraper in src.id == scraper.id }) } 20 | .filter { src in return !favorite.contains(where: { scraper in src.id == scraper.id }) } 21 | } 22 | 23 | func toogleActive(source: Source) { 24 | do { 25 | try database.write { db in 26 | let scraper = try Scraper.fetchOne(db, id: source.id) 27 | if var scraper = scraper { 28 | scraper.isActive.toggle() 29 | scraper.position = 99999 30 | try scraper.save(db) 31 | } else { 32 | var scraper = Scraper(from: source) 33 | scraper.isActive = true 34 | scraper.position = 99999 35 | try scraper.save(db) 36 | } 37 | } 38 | } catch(let err) { 39 | print(err) 40 | } 41 | } 42 | 43 | func toogleFavorite(source: Source) { 44 | do { 45 | try database.write { db in 46 | let scraper = try Scraper.fetchOne(db, id: source.id) 47 | if var scraper = scraper { 48 | scraper.isFavorite.toggle() 49 | scraper.position = 99999 50 | try scraper.save(db) 51 | } else { 52 | var scraper = Scraper(from: source) 53 | scraper.isFavorite = true 54 | scraper.position = 99999 55 | try scraper.save(db) 56 | } 57 | } 58 | } catch(let err) { 59 | print(err) 60 | } 61 | } 62 | 63 | func onMove(scrapers: [Scraper], offsets: IndexSet, position: Int) { 64 | do { 65 | var sc = scrapers 66 | 67 | try database.write { db in 68 | // change the order of the items in the array 69 | sc.move(fromOffsets: offsets, toOffset: position) 70 | 71 | try sc 72 | .enumerated() 73 | .forEach { d in 74 | var scraper = d.element 75 | scraper.position = d.offset; 76 | 77 | try scraper.save(db) 78 | } 79 | } 80 | } catch(let err) { 81 | print(err) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Features/Sources/ExploreTab/ViewModels/SearchScraperVM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MangaScraper 3 | import SwiftUI 4 | import DataKit 5 | 6 | class SearchScraperVM: ObservableObject { 7 | private var database = AppDatabase.shared.database 8 | 9 | var scraper: Scraper 10 | var nextPage = 1 11 | var oldSearch: String? 12 | 13 | @Published var isLoading = true 14 | @Published var hasNextPage = false 15 | @Published var mangas = [SourceSmallManga]() 16 | @Published var selectedManga: SourceSmallManga? 17 | 18 | init(scraper: Scraper) { 19 | self.scraper = scraper 20 | } 21 | 22 | @MainActor 23 | func fetchData(textToSearch: String) async { 24 | guard !textToSearch.isEmpty else { return } 25 | 26 | do { 27 | isLoading = true 28 | 29 | if oldSearch != textToSearch { 30 | self.mangas = [] 31 | self.hasNextPage = false 32 | self.nextPage = 1 33 | } 34 | 35 | guard let data = try await scraper.asSource()?.fetchSearchManga(query: textToSearch, page: nextPage) else { throw "Error searching for \(textToSearch)" } 36 | 37 | withAnimation { 38 | self.hasNextPage = data.hasNextPage 39 | self.mangas += data.mangas 40 | self.isLoading = false 41 | self.nextPage += 1 42 | self.oldSearch = textToSearch 43 | } 44 | } catch { 45 | print(error) 46 | } 47 | } 48 | 49 | @MainActor 50 | func fetchMoreIfPossible(for manga: SourceSmallManga) async { 51 | guard let oldSearch = oldSearch else { return } 52 | 53 | if mangas.last == manga && hasNextPage { 54 | return await fetchData(textToSearch: oldSearch) 55 | } 56 | } 57 | 58 | @MainActor 59 | func addToCollection(smallManga: SourceSmallManga, collection: MangaCollection) async { 60 | guard let sourceManga = try? await scraper.asSource()?.fetchMangaDetail(id: smallManga.id) else { return } 61 | 62 | do { 63 | try await database.write { [scraper, smallManga, sourceManga, collection] db -> Void in 64 | guard var manga = try Manga.all().forMangaId(smallManga.id, scraper.id).fetchOne(db) else { 65 | var manga = Manga(from: sourceManga, sourceId: scraper.id) 66 | manga.mangaCollectionId = collection.id 67 | try manga.save(db) 68 | 69 | for info in sourceManga.chapters.enumerated() { 70 | let chapter = MangaChapter(from: info.element, position: info.offset, mangaId: manga.id, scraperId: scraper.id) 71 | try chapter.save(db) 72 | } 73 | 74 | return 75 | } 76 | 77 | manga.mangaCollectionId = collection.id 78 | 79 | return try manga.save(db) 80 | } 81 | } catch(let err) { 82 | print(err) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Features/Sources/HistoryTab/HistoryTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistoryTabView.swift 3 | // HistoryTabView 4 | // 5 | // Created by Stephan Deumier on 12/09/2021. 6 | // 7 | 8 | import SwiftUI 9 | import GRDBQuery 10 | import Combine 11 | import DataKit 12 | import SharedUI 13 | import MangaDetail 14 | 15 | public struct HistoryTabView: View { 16 | @Query(ChaptersHistoryRequest(filter: .read, searchTerm: "")) var list: [ChaptersHistory] 17 | 18 | public init() {} 19 | 20 | public var body: some View { 21 | NavigationView { 22 | List { 23 | ForEach(list) { data in 24 | ChapterRow(data) 25 | } 26 | } 27 | .toolbar { 28 | ToolbarItem(placement: .principal) { 29 | Picker("Chapter Status", selection: $list.filter) { 30 | Text(ChapterStatusHistory.read.rawValue).tag(ChapterStatusHistory.read) 31 | Text(ChapterStatusHistory.all.rawValue).tag(ChapterStatusHistory.all) 32 | } 33 | .frame(maxWidth: 150) 34 | .pickerStyle(.segmented) 35 | } 36 | 37 | ToolbarItem(placement: .navigationBarTrailing) { 38 | EditButton() 39 | } 40 | } 41 | .listStyle(PlainListStyle()) 42 | .id($list.filter.wrappedValue) 43 | .searchable(text: $list.searchTerm) 44 | .navigationBarTitle($list.filter.wrappedValue == .read ? "Reading history" : "Update history", displayMode: .large) 45 | } 46 | .navigationViewStyle(.columns) 47 | } 48 | 49 | @ViewBuilder 50 | func ChapterRow(_ data: ChaptersHistory) -> some View { 51 | NavigationLink(destination: MangaDetail(mangaId: data.manga.mangaId, scraper: data.scraper)) { 52 | HStack { 53 | MangaCard(imageUrl: data.manga.cover.absoluteString) 54 | .mangaCardFrame(width: 90, height: 120) 55 | .id(data.id) 56 | 57 | VStack(alignment: .leading) { 58 | Text(data.manga.title) 59 | .lineLimit(2) 60 | .font(.body) 61 | .allowsTightening(true) 62 | Text(data.chapter.title) 63 | .lineLimit(1) 64 | .font(.callout.italic()) 65 | 66 | Group { 67 | if $list.filter.wrappedValue == .read { Text("Read at: \(data.chapter.readAt?.formatted() ?? "No date...")") } 68 | if $list.filter.wrappedValue == .all { Text("Uploaded at: \(data.chapter.dateSourceUpload.formatted())") } 69 | } 70 | .font(.footnote) 71 | } 72 | } 73 | .frame(height: 120) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Features/Sources/LibraryTab/Components/MangaLibraryContextMenu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaLibraryContextMenu.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 04/07/2021. 6 | // 7 | 8 | import SwiftUI 9 | import DataKit 10 | 11 | public struct MangaLibraryContextMenu: View { 12 | @Environment(\.appDatabase) var appDB 13 | 14 | var manga: PartialManga 15 | var count: Int 16 | 17 | public init(manga: PartialManga, count: Int) { 18 | self.manga = manga 19 | self.count = count 20 | } 21 | 22 | public var body: some View { 23 | if count != 0 { 24 | Button(action: { markAllChapterAs(newSatus: .read) }) { 25 | Text("Mark as read") 26 | } 27 | } 28 | 29 | if count == 0 { 30 | Button(action: { markAllChapterAs(newSatus: .unread) }) { 31 | Text("Mark as unread") 32 | } 33 | } 34 | } 35 | 36 | func markAllChapterAs(newSatus: ChapterStatus) { 37 | try? appDB.database.write { db in 38 | try MangaChapter.markAllAs(newStatus: newSatus, db: db, mangaId: manga.id) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Features/Sources/LibraryTab/LibraryTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryView.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 31/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | import MangaScraper 10 | import GRDBQuery 11 | import DataKit 12 | import SharedUI 13 | import MangaDetail 14 | 15 | public struct LibraryTabView: View { 16 | @Environment(\.appDatabase) var appDB 17 | @EnvironmentObject var libraryRefresh: LibraryUpdater 18 | 19 | @Query(DetailedMangaCollectionRequest()) var collections 20 | 21 | @State var editMode: EditMode = .inactive 22 | @State var newCollectionName = "" 23 | 24 | public init() {} 25 | 26 | public var body: some View { 27 | NavigationStack { 28 | List { 29 | Section("User Collection") { 30 | ForEach(collections) { info in 31 | NavigationLink(value: info.mangaCollection) { 32 | Label(info.mangaCollection.name, systemImage: "square.grid.2x2") 33 | .badge("\(info.mangaCount)") 34 | .padding(.vertical) 35 | } 36 | } 37 | .onDelete(perform: onDelete) 38 | .onMove(perform: onMove) 39 | 40 | if editMode.isEditing { 41 | TextField("New collection name", text: $newCollectionName) 42 | .padding(.vertical) 43 | .submitLabel(.done) 44 | .onSubmit(saveNewCollection) 45 | } 46 | } 47 | 48 | Section("Dynamic Collection") { 49 | NavigationLink(destination: ByGenreListPage()) { 50 | Text("By Genres") 51 | } 52 | 53 | NavigationLink(destination: BySourceListPage()) { 54 | Text("By Source List") 55 | } 56 | } 57 | } 58 | .toolbar { 59 | ToolbarItemGroup(placement: .navigationBarTrailing) { 60 | EditButton() 61 | } 62 | } 63 | .navigationTitle("Collections") 64 | .environment(\.editMode, $editMode) 65 | .queryObservation(.always) 66 | .navigationDestination(for: DetailedMangaInList.self) { data in 67 | MangaDetail(mangaId: data.manga.mangaId, scraper: data.scraper) 68 | } 69 | .navigationDestination(for: MangaCollection.self) { data in 70 | CollectionPage(collection: data) 71 | } 72 | } 73 | .navigationViewStyle(.stack) 74 | } 75 | 76 | func saveNewCollection() { 77 | guard !newCollectionName.isEmpty else { return } 78 | let lastPosition = (collections.last?.mangaCollection.position ?? 0) + 1 79 | 80 | do { 81 | try appDB.database.write { db in 82 | let collection = MangaCollection(id: UUID(), name: newCollectionName, position: lastPosition) 83 | try collection.save(db) 84 | } 85 | } catch(let err) { 86 | print(err) 87 | } 88 | 89 | newCollectionName = "" 90 | } 91 | 92 | func onDelete(_ offsets: IndexSet) { 93 | offsets 94 | .map { collections[$0] } 95 | .forEach { collection in 96 | do { 97 | let _ = try appDB.database.write { db in 98 | try collection.mangaCollection.delete(db) 99 | } 100 | } catch(let err) { 101 | print(err) 102 | } 103 | } 104 | } 105 | 106 | func onMove(_ offsets: IndexSet, _ position: Int) { 107 | try? appDB.database.write { db in 108 | var revisedItems: [MangaCollection] = collections.map{ $0.mangaCollection } 109 | 110 | // change the order of the items in the array 111 | revisedItems.move(fromOffsets: offsets, toOffset: position) 112 | 113 | // update the position attribute in revisedItems to 114 | // persist the new order. This is done in reverse order 115 | // to minimize changes to the indices. 116 | for reverseIndex in stride(from: revisedItems.count - 1, through: 0, by: -1) { 117 | revisedItems[reverseIndex].position = reverseIndex 118 | try revisedItems[reverseIndex].save(db) 119 | } 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Features/Sources/LibraryTab/Screens/ByGenre.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ByGenreListPage.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 04/10/2021. 6 | // 7 | 8 | import SwiftUI 9 | import GRDBQuery 10 | import DataKit 11 | import DynamicCollection 12 | 13 | public struct ByGenreListPage: View { 14 | @Query(DistinctMangaGenreRequest()) var genres: [GenreWithMangaCount] 15 | 16 | public init() {} 17 | 18 | public var body: some View { 19 | List(genres) { genre in 20 | NavigationLink(destination: MangaInCollectionForGenre(genre: genre.genre, inModal: false)) { 21 | Text(genre.genre) 22 | .badge("\(genre.mangaCount)") 23 | } 24 | } 25 | .navigationTitle("By Genres") 26 | .navigationBarTitleDisplayMode(.inline) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Features/Sources/LibraryTab/Screens/BySource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BySourceListPage.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 04/10/2021. 6 | // 7 | 8 | import SwiftUI 9 | import MangaScraper 10 | import GRDBQuery 11 | import DataKit 12 | import DynamicCollection 13 | 14 | public struct BySourceListPage: View { 15 | @Query(ScraperWithMangaInCollection()) var scrapers 16 | 17 | public init() {} 18 | 19 | public var body: some View { 20 | List { 21 | ForEach(scrapers) { scraper in 22 | NavigationLink(destination: MangaForSourcePage(scraper: scraper.scraper)) { 23 | Text(scraper.scraper.name) 24 | .badge(scraper.mangaCount) 25 | } 26 | } 27 | } 28 | .navigationBarTitleDisplayMode(.inline) 29 | .navigationTitle("By Sources") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Features/Sources/LibraryTab/Screens/CollectionPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ManageCollectionsModal.swift 3 | // Dokusho (iOS) 4 | // 5 | // Created by Stephan Deumier on 26/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import GRDBQuery 10 | import Combine 11 | import DataKit 12 | import Common 13 | import SharedUI 14 | import MangaDetail 15 | import DynamicCollection 16 | 17 | public struct CollectionPage: View { 18 | @Environment(\.appDatabase) var appDatabase 19 | @EnvironmentObject var libraryUpdater: LibraryUpdater 20 | @Preference(\.onlyUpdateAllRead) var onlyUpdateAllRead 21 | 22 | @Query var collection: MangaCollection? 23 | @Query var list: [DetailedMangaInList] 24 | 25 | @State var showFilter = false 26 | @State var reload = true 27 | @State var selectedGenre: String? 28 | 29 | @State private var refreshTask: Task? 30 | 31 | public init(collection : MangaCollection) { 32 | _collection = Query(OneMangaCollectionRequest(collectionId: collection.id)) 33 | _list = Query(DetailedMangaInListRequest(requestType: .collection(collectionId: collection.id))) 34 | } 35 | 36 | public var body: some View { 37 | if let collection = collection { 38 | Group { 39 | if collection.useList ?? false { ListView() } 40 | else { GridView() } 41 | } 42 | .sheet(isPresented: $showFilter) { CollectionSettings(collection: collection) } 43 | .searchable(text: $list.searchTerm) 44 | .toolbar { toolbar } 45 | .navigationTitle("\(collection.name) (\(list.count))") 46 | .queryObservation(.always) 47 | .onDisappear { cancelRefresh() } 48 | } 49 | } 50 | 51 | @ViewBuilder 52 | func GridView() -> some View { 53 | ScrollView { 54 | MangaList(mangas: list) { data in 55 | MangaInGrid(data: data) 56 | } 57 | } 58 | .refreshable { await refreshLibrary(libraryUpdater: libraryUpdater, collection: collection!, onlyUpdateAllRead: onlyUpdateAllRead) } 59 | } 60 | 61 | @ViewBuilder 62 | func MangaInGrid(data: DetailedMangaInList) -> some View { 63 | NavigationLink(value: data) { 64 | MangaCard(title: data.manga.title, imageUrl: data.manga.cover.absoluteString, chapterCount: data.unreadChapterCount) 65 | .contextMenu { MangaLibraryContextMenu(manga: data.manga, count: data.unreadChapterCount) } 66 | .mangaCardFrame() 67 | .id(data.id) 68 | } 69 | } 70 | 71 | @ViewBuilder 72 | func ListView() -> some View { 73 | List(list) { data in 74 | MangaInList(data: data) 75 | } 76 | .refreshable { await refreshLibrary(libraryUpdater: libraryUpdater, collection: collection!, onlyUpdateAllRead: onlyUpdateAllRead) } 77 | .listStyle(PlainListStyle()) 78 | } 79 | 80 | @ViewBuilder 81 | func MangaInList(data: DetailedMangaInList) -> some View { 82 | NavigationLink(value: data) { 83 | HStack { 84 | MangaCard(imageUrl: data.manga.cover.absoluteString, chapterCount: data.unreadChapterCount) 85 | .mangaCardFrame(width: 90, height: 120) 86 | .id(data.id) 87 | 88 | Text(data.manga.title) 89 | .lineLimit(3) 90 | } 91 | .contextMenu { MangaLibraryContextMenu(manga: data.manga, count: data.unreadChapterCount) } 92 | .frame(height: 120) 93 | } 94 | } 95 | 96 | var toolbar: some ToolbarContent { 97 | ToolbarItemGroup(placement: .navigationBarTrailing) { 98 | Button(action: { showFilter.toggle() }) { 99 | Image(systemName: "line.3.horizontal.decrease") 100 | } 101 | } 102 | } 103 | 104 | func refreshLibrary(libraryUpdater: LibraryUpdater, collection: MangaCollection, onlyUpdateAllRead: Bool) async { 105 | guard refreshTask == nil else { return } 106 | 107 | refreshTask = Task { 108 | try? await libraryUpdater.refreshCollection(collection: collection, onlyAllRead: onlyUpdateAllRead) 109 | } 110 | 111 | try? await refreshTask?.value 112 | } 113 | 114 | func cancelRefresh() { 115 | refreshTask?.cancel() 116 | } 117 | 118 | func selectGenre(genre: String) -> Void { 119 | self.selectedGenre = genre 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Features/Sources/LibraryTab/Screens/CollectionSettings.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import GRDBQuery 4 | import Combine 5 | import DataKit 6 | import Common 7 | import SharedUI 8 | 9 | public struct CollectionSettings: View { 10 | @Environment(\.appDatabase) var appDatabase 11 | @Query var collection: MangaCollection? 12 | 13 | @State var collectionOrder: MangaCollectionOrder 14 | @State var collectionFilter: MangaCollectionFilter 15 | @State var useList: Bool 16 | 17 | public init(collection : MangaCollection) { 18 | _collection = Query(OneMangaCollectionRequest(collectionId: collection.id)) 19 | _collectionOrder = .init(initialValue: collection.order) 20 | _collectionFilter = .init(initialValue: collection.filter) 21 | _useList = .init(initialValue: collection.useList ?? false) 22 | } 23 | 24 | public var body: some View { 25 | NavigationView { 26 | List { 27 | Section("Filter") { 28 | Picker("Change collection filter", selection: $collectionFilter) { 29 | ForEach(MangaCollectionFilter.allCases, id: \.self) { filter in 30 | Text(filter.rawValue).tag(filter) 31 | } 32 | } 33 | } 34 | 35 | Section("Order") { 36 | Picker("Change collection order field", selection: $collectionOrder.field) { 37 | ForEach(MangaCollectionOrder.Field.allCases, id: \.self) { filter in 38 | Text(filter.rawValue).tag(filter) 39 | } 40 | } 41 | Picker("Change collection order direction", selection: $collectionOrder.direction) { 42 | ForEach(MangaCollectionOrder.Direction.allCases, id: \.self) { filter in 43 | Text(filter.rawValue).tag(filter) 44 | } 45 | } 46 | } 47 | 48 | Section("Presentation") { 49 | Toggle("Show as list", isOn: $useList) 50 | } 51 | } 52 | .navigationTitle(Text("Modify Filter")) 53 | .onChange(of: $collectionFilter.wrappedValue) { _, filter in updateCollectionFilter(newFilter: filter) } 54 | .onChange(of: $collectionOrder.field.wrappedValue) { _, field in updateCollectionOrder(direction: nil, field: field) } 55 | .onChange(of: $collectionOrder.direction.wrappedValue) { _, direction in updateCollectionOrder(direction: direction, field: nil) } 56 | .onChange(of: $useList.wrappedValue) { _, useList in updateCollectionUseList(d: useList) } 57 | } 58 | } 59 | } 60 | 61 | extension CollectionSettings { 62 | func updateCollectionUseList(d: Bool) { 63 | Task { 64 | guard let collection = collection else { return } 65 | 66 | do { 67 | try await appDatabase.database.write { db in 68 | guard var foundCollection = try MangaCollection.fetchOne(db, id: collection.id) else { return } 69 | 70 | try foundCollection.updateChanges(db) { 71 | $0.useList = d 72 | } 73 | } 74 | } catch(let err) { 75 | print(err) 76 | } 77 | } 78 | } 79 | 80 | func updateCollectionFilter(newFilter: MangaCollectionFilter) { 81 | Task { 82 | guard let collection = collection else { return } 83 | 84 | do { 85 | try await appDatabase.database.write { db in 86 | guard var foundCollection = try MangaCollection.fetchOne(db, id: collection.id) else { return } 87 | 88 | try foundCollection.updateChanges(db) { 89 | $0.filter = newFilter 90 | } 91 | } 92 | } catch(let err) { 93 | print(err) 94 | } 95 | } 96 | } 97 | 98 | func updateCollectionOrder(direction: MangaCollectionOrder.Direction? = nil, field: MangaCollectionOrder.Field? = nil) { 99 | Task { 100 | guard let collection = collection else { return } 101 | 102 | do { 103 | try await appDatabase.database.write { db in 104 | guard var foundCollection = try MangaCollection.fetchOne(db, id: collection.id) else { return } 105 | 106 | try foundCollection.updateChanges(db) { 107 | if let direction = direction { $0.order.direction = direction } 108 | if let field = field { $0.order.field = field } 109 | } 110 | } 111 | } catch(let err) { 112 | print(err) 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Features/Sources/MangaDetail/Components/ChapterListInformation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterList.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 04/07/2021. 6 | // 7 | 8 | import SwiftUI 9 | import GRDBQuery 10 | import DataKit 11 | import Reader 12 | 13 | public struct ChapterListInformation: View { 14 | @EnvironmentObject var readerManager: ReaderManager 15 | 16 | @Query var chapters: [MangaChapter] 17 | @StateObject var vm: ChapterListVM 18 | 19 | var manga: Manga 20 | var scraper: Scraper 21 | 22 | public init(manga: Manga, scraper: Scraper) { 23 | self.manga = manga 24 | self.scraper = scraper 25 | _vm = .init(wrappedValue: .init(manga: manga, scraper: scraper)) 26 | _chapters = Query(MangaChaptersRequest(manga: manga)) 27 | } 28 | 29 | public var body: some View { 30 | LazyVStack { 31 | HStack { 32 | Text("Chapter List") 33 | .font(.title3) 34 | 35 | Spacer() 36 | 37 | HStack { 38 | ChaptersButton(filter: $chapters.filterAll, order: $chapters.ascendingOrder) 39 | } 40 | } 41 | .frame(height: 24) 42 | .padding(.vertical, 10) 43 | .padding(.horizontal, 15) 44 | 45 | ChapterCollections() 46 | .padding(.horizontal, 10) 47 | } 48 | } 49 | 50 | @ViewBuilder 51 | func ChapterCollections() -> some View { 52 | Group { 53 | if let chapter = vm.nextUnreadChapter(chapters: chapters) { 54 | Group { 55 | if let url = chapter.externalUrl { 56 | Link(destination: URL(string: url)!) { 57 | NextUnreadChapter() 58 | } 59 | } else { 60 | Button(action: { readerManager.selectChapter(chapter: chapter, manga: vm.manga, scraper: vm.scraper, chapters: chapters) }) { 61 | NextUnreadChapter() 62 | } 63 | } 64 | } 65 | .buttonStyle(.bordered) 66 | .controlSize(.large) 67 | .padding(.horizontal) 68 | } 69 | 70 | ForEach(chapters) { chapter in 71 | ChapterListRow(vm: vm, chapter: chapter, chapters: chapters) 72 | } 73 | } 74 | } 75 | 76 | @ViewBuilder 77 | func NextUnreadChapter() -> some View { 78 | Text("Read next unread chapter") 79 | .frame(minWidth: 0, maxWidth: .infinity) 80 | } 81 | 82 | @ViewBuilder 83 | func ChaptersButton(filter: Binding, order: Binding) -> some View { 84 | Button(action: { filter.wrappedValue.toggle() }) { 85 | Image(systemName: "line.3.horizontal.decrease.circle") 86 | .resizable() 87 | .scaledToFit() 88 | .symbolVariant(filter.wrappedValue == true ? .none : .fill) 89 | } 90 | .padding(.trailing, 5) 91 | 92 | Button(action: { order.wrappedValue.toggle() }) { 93 | Image(systemName: "chevron.up.chevron.down") 94 | .resizable() 95 | .scaledToFit() 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Features/Sources/MangaDetail/Components/ChapterListRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterListRow.swift 3 | // Dokusho (iOS) 4 | // 5 | // Created by Stephan Deumier on 26/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import DataKit 10 | import Reader 11 | 12 | 13 | public struct ChapterListRow: View { 14 | @EnvironmentObject var readerManager: ReaderManager 15 | @ObservedObject var vm: ChapterListVM 16 | 17 | var chapter: MangaChapter 18 | var chapters: [MangaChapter] 19 | 20 | public init(vm: ChapterListVM, chapter: MangaChapter, chapters: [MangaChapter]) { 21 | self.chapter = chapter 22 | self.chapters = chapters 23 | self._vm = .init(wrappedValue: vm) 24 | } 25 | 26 | public var body: some View { 27 | HStack { 28 | if let url = chapter.externalUrl { 29 | Link(destination: URL(string: url)!) { 30 | Content() 31 | } 32 | .buttonStyle(.plain) 33 | .padding(.vertical, 5) 34 | } else { 35 | Button(action: { readerManager.selectChapter(chapter: chapter, manga: vm.manga, scraper: vm.scraper, chapters: chapters) }) { 36 | Content() 37 | } 38 | .buttonStyle(.plain) 39 | .padding(.vertical, 5) 40 | } 41 | } 42 | .foregroundColor(chapter.status == .read ? Color.gray : Color.blue) 43 | .contextMenu { ChapterRowContextMenu() } 44 | } 45 | 46 | @ViewBuilder 47 | func Content() -> some View { 48 | HStack { 49 | VStack(alignment: .leading) { 50 | Text(chapter.title) 51 | Text(chapter.dateSourceUpload.formatted()) 52 | .font(.system(size: 12)) 53 | if let readAt = chapter.readAt { 54 | Text("Read At: \(readAt.formatted())") 55 | .font(.system(size: 10)) 56 | } 57 | } 58 | } 59 | 60 | Spacer() 61 | 62 | if chapter.externalUrl != nil { 63 | Image(systemName: "arrow.up.forward.app") 64 | } else { 65 | Button(action: { print("download")}) { 66 | Image(systemName: "icloud.and.arrow.down") 67 | } 68 | } 69 | } 70 | 71 | @ViewBuilder 72 | func ChapterRowContextMenu() -> some View { 73 | if chapter.isUnread { 74 | Button(action: { vm.changeChapterStatus(for: chapter, status: .read) }) { 75 | Text("Mark as read") 76 | } 77 | } 78 | else { 79 | Button(action: { vm.changeChapterStatus(for: chapter, status: .unread) }) { 80 | Text("Mark as unread") 81 | } 82 | } 83 | 84 | if vm.hasPreviousUnreadChapter(for: chapter, chapters: chapters) { 85 | Button(action: { vm.changePreviousChapterStatus(for: chapter, status: .read, in: chapters) }) { 86 | Text("Mark previous as read") 87 | } 88 | } 89 | else { 90 | Button(action: { vm.changePreviousChapterStatus(for: chapter, status: .unread, in: chapters) }) { 91 | Text("Mark previous as unread") 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Features/Sources/MangaDetail/MangaDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaDetailView.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 06/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import GRDBQuery 10 | import DataKit 11 | import Reader 12 | import Common 13 | import SharedUI 14 | import SwiftUILayouts 15 | 16 | public struct MangaDetail: View { 17 | @Environment(\.horizontalSizeClass) var horizontalSize 18 | @Query(MangaCollectionRequest()) var collections 19 | @Query var data: MangaWithDetail? 20 | 21 | @StateObject var vm: MangaDetailVM 22 | @StateObject var orientation: DeviceOrientation = DeviceOrientation() 23 | @StateObject var readerManager = ReaderManager() 24 | 25 | let selectGenre: ((_ genre: String) -> Void)? 26 | 27 | public init(mangaId: String, scraper: Scraper, selectGenre: ((_ genre: String) -> Void)? = nil) { 28 | _data = .init(.init(mangaId: mangaId, scraper: scraper)) 29 | _vm = .init(wrappedValue: .init(for: scraper, mangaId: mangaId)) 30 | 31 | self.selectGenre = selectGenre 32 | } 33 | 34 | public var body: some View { 35 | Group { 36 | if vm.error && data == nil { 37 | VStack { 38 | Text("Something weird happened, try again") 39 | AsyncButton(action: { await vm.update() }) { 40 | Image(systemName: "arrow.clockwise") 41 | } 42 | } 43 | } 44 | else if let data = data { 45 | if horizontalSize == .regular { 46 | LargeBody(data) 47 | } else { 48 | CompactBody(data) 49 | } 50 | } 51 | else { 52 | ProgressView() 53 | .progressViewStyle(.circular) 54 | .frame(maxWidth: .infinity) 55 | } 56 | } 57 | .navigationBarTitleDisplayMode(.inline) 58 | .toolbar { 59 | ToolbarItem(placement: .navigationBarTrailing) { 60 | Link(destination: self.vm.getMangaURL()) { 61 | Image(systemName: "safari") 62 | } 63 | } 64 | } 65 | .fullScreenCover(item: $readerManager.selectedChapter) { data in 66 | ReaderView(vm: .init(manga: data.manga, chapter: data.chapter, scraper: data.scraper, chapters: data.chapters), readerManager: readerManager) 67 | } 68 | .environmentObject(readerManager) 69 | } 70 | 71 | @ViewBuilder 72 | func LargeBody(_ data: MangaWithDetail) -> some View { 73 | Grid { 74 | GridRow { 75 | ScrollView { 76 | HeaderRow(data) 77 | ActionRow(data) 78 | SynopsisRow(synopsis: data.manga.synopsis) 79 | GenreRow(genres: data.manga.genres) 80 | } 81 | .id("Detail") 82 | .gridCellColumns(6) 83 | 84 | HStack { 85 | Divider() 86 | } 87 | .gridCellColumns(1) 88 | 89 | ScrollView { 90 | ChapterListInformation(manga: data.manga, scraper: vm.scraper) 91 | .disabled(vm.refreshing) 92 | .padding(.bottom) 93 | } 94 | .id("Chapter") 95 | .refreshable { await vm.update() } 96 | .gridCellColumns(5) 97 | } 98 | } 99 | } 100 | 101 | @ViewBuilder 102 | func CompactBody(_ data: MangaWithDetail) -> some View { 103 | ScrollView { 104 | HeaderRow(data) 105 | ActionRow(data) 106 | SynopsisRow(synopsis: data.manga.synopsis) 107 | GenreRow(genres: data.manga.genres) 108 | ChapterListInformation(manga: data.manga, scraper: data.scraper!) 109 | .disabled(vm.refreshing) 110 | .padding(.bottom) 111 | } 112 | .refreshable { await vm.update() } 113 | } 114 | 115 | @ViewBuilder 116 | func HeaderRow(_ data: MangaWithDetail) -> some View { 117 | HStack(alignment: .top) { 118 | MangaCard(imageUrl: data.manga.cover.absoluteString) 119 | .mangaCardFrame() 120 | .padding(.leading, 10) 121 | 122 | VStack(spacing: 0) { 123 | VStack(alignment: .leading) { 124 | Text(data.manga.title) 125 | .textSelection(.enabled) 126 | .lineLimit(2) 127 | .fixedSize(horizontal: false, vertical: true) 128 | .font(.subheadline.bold()) 129 | } 130 | .padding(.bottom, 5) 131 | 132 | VStack(alignment: .center) { 133 | VStack { 134 | ForEach(data.manga.authors) { author in 135 | Text(author) 136 | .font(.caption.italic()) 137 | } 138 | } 139 | .padding(.bottom, 5) 140 | 141 | Text(data.manga.status.rawValue) 142 | .font(.callout.bold()) 143 | .padding(.bottom, 5) 144 | 145 | Text(data.scraper?.name ?? "No Name") 146 | .font(.callout.bold()) 147 | } 148 | } 149 | .frame(maxWidth: .infinity) 150 | } 151 | } 152 | 153 | @ViewBuilder 154 | func ActionRow(_ data: MangaWithDetail) -> some View { 155 | ControlGroup { 156 | Button(action: { 157 | withAnimation { 158 | vm.addToCollection.toggle() 159 | } 160 | }) { 161 | VStack(alignment: .center, spacing: 1) { 162 | Image(systemName: "heart") 163 | .symbolVariant(data.mangaCollection != nil ? .fill : .none) 164 | Text(data.mangaCollection?.name ?? "Favoris") 165 | } 166 | } 167 | .disabled(collections.count == 0) 168 | .buttonStyle(.plain) 169 | .actionSheet(isPresented: $vm.addToCollection) { 170 | var actions: [ActionSheet.Button] = [] 171 | 172 | collections.forEach { col in 173 | actions.append(.default( 174 | Text(col.name), 175 | action: { 176 | vm.updateMangaInCollection(data: data, col.id) 177 | } 178 | )) 179 | } 180 | 181 | if let collectionName = data.mangaCollection?.name { 182 | actions.append(.destructive( 183 | Text("Remove from \(collectionName)"), 184 | action: { 185 | vm.updateMangaInCollection(data: data) 186 | } 187 | )) 188 | } 189 | 190 | actions.append(.cancel()) 191 | 192 | return ActionSheet(title: Text("Choose collection"), buttons: actions) 193 | } 194 | 195 | Divider() 196 | .padding(.horizontal) 197 | 198 | AsyncButton(action: { await vm.resetCache() }) { 199 | VStack(alignment: .center, spacing: 1) { 200 | Image(systemName: "xmark.bin.circle") 201 | Text("Reset cache") 202 | } 203 | } 204 | .disabled(true) 205 | } 206 | .controlGroupStyle(.navigation) 207 | .frame(height: 50) 208 | .padding(.top) 209 | .padding(.bottom, 5) 210 | .padding(.horizontal) 211 | } 212 | 213 | @ViewBuilder 214 | func SynopsisRow(synopsis: String) -> some View { 215 | VStack(spacing: 5) { 216 | Text(synopsis) 217 | .lineLimit(vm.showMoreDesc ? nil : 4) 218 | .fixedSize(horizontal: false, vertical: true) 219 | 220 | HStack { 221 | Spacer() 222 | Button(action: { withAnimation { 223 | vm.showMoreDesc.toggle() 224 | } }) { 225 | Text("Show \(!vm.showMoreDesc ? "more" : "less")") 226 | } 227 | } 228 | } 229 | .padding([.bottom, .horizontal]) 230 | } 231 | 232 | @ViewBuilder 233 | func GenreRow(genres: [String]) -> some View { 234 | FlowLayout(alignment: .center) { 235 | ForEach(genres) { genre in 236 | Button(genre, action: { selectGenre?(genre) }) 237 | .buttonStyle(.bordered) 238 | } 239 | } 240 | } 241 | } 242 | 243 | -------------------------------------------------------------------------------- /Features/Sources/MangaDetail/ViewModels/ChapterListVM.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterListVM.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 04/07/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import DataKit 11 | 12 | public class ChapterListVM: ObservableObject { 13 | private let database = AppDatabase.shared.database 14 | 15 | var manga: Manga 16 | var scraper: Scraper 17 | 18 | @Published var error: Error? 19 | @Published var selectedChapter: MangaChapter? 20 | 21 | public init(manga: Manga, scraper: Scraper) { 22 | self.manga = manga 23 | self.scraper = scraper 24 | } 25 | 26 | func changeChapterStatus(for chapter: MangaChapter, status: ChapterStatus) { 27 | do { 28 | try database.write { db in 29 | try MangaChapter.markChapterAs(newStatus: status, db: db, chapterId: chapter.id) 30 | } 31 | } catch(let err) { 32 | print(err) 33 | } 34 | } 35 | 36 | func changePreviousChapterStatus(for chapter: MangaChapter, status: ChapterStatus, in chapters: [MangaChapter]) { 37 | do { 38 | try database.write { db in 39 | try chapters 40 | .filter { status == .unread ? !$0.isUnread : $0.isUnread } 41 | .filter { chapter.position < $0.position } 42 | .forEach { try MangaChapter.markChapterAs(newStatus: status, db: db, chapterId: $0.id) } 43 | } 44 | } catch(let err) { 45 | print(err) 46 | } 47 | } 48 | 49 | func hasPreviousUnreadChapter(for chapter: MangaChapter, chapters: [MangaChapter]) -> Bool { 50 | return chapters 51 | .filter { chapter.position < $0.position } 52 | .contains { $0.isUnread } 53 | } 54 | 55 | func nextUnreadChapter(chapters: [MangaChapter]) -> MangaChapter? { 56 | return chapters 57 | .sorted { $0.position > $1.position } 58 | .first { $0.isUnread } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Features/Sources/MangaDetail/ViewModels/MangaDetailVM.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaDetailVM.swift 3 | // Dokusho (iOS) 4 | // 5 | // Created by Stephan Deumier on 14/06/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import MangaScraper 11 | import GRDB 12 | import DataKit 13 | 14 | @MainActor 15 | public class MangaDetailVM: ObservableObject { 16 | private let database = AppDatabase.shared.database 17 | 18 | let scraper: Scraper 19 | let mangaId: String 20 | 21 | @Published var error = false 22 | @Published var showMoreDesc = false 23 | @Published var addToCollection = false 24 | @Published var refreshing = false 25 | @Published var selectedChapter: MangaChapter? 26 | 27 | public init(for scraper: Scraper, mangaId: String) { 28 | self.scraper = scraper 29 | self.mangaId = mangaId 30 | } 31 | 32 | @MainActor 33 | func update() async { 34 | defer { 35 | self.refreshing = false 36 | } 37 | 38 | do { 39 | guard let source = scraper.asSource() else { throw "Source Not found" } 40 | 41 | let sourceManga = try await source.fetchMangaDetail(id: mangaId) 42 | 43 | try _ = await database.write { [scraper] db in 44 | try Manga.updateFromSource(db: db, scraper: scraper, data: sourceManga) 45 | } 46 | } catch { 47 | print(error) 48 | self.error = true 49 | } 50 | } 51 | 52 | func getMangaURL() -> URL { 53 | return scraper.asSource()?.mangaUrl(mangaId: self.mangaId) ?? URL(string: "")! 54 | } 55 | 56 | func getSourceName() -> String { 57 | return scraper.name 58 | } 59 | 60 | // TODO: Rework reset cache to avoid deleting chapter read/unread info 61 | func resetCache() async {} 62 | 63 | func updateMangaInCollection(data: MangaWithDetail, _ collectionId: MangaCollection.ID? = nil) { 64 | do { 65 | try database.write { db in 66 | try Manga.updateCollection(id: data.manga.id, collectionId: collectionId, db) 67 | } 68 | } catch { 69 | withAnimation { 70 | self.error = true 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Features/Sources/MangaScraper/Extension/Array.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array.swift 3 | // Hanako 4 | // 5 | // Created by Stephan Deumier on 30/12/2020. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | extension Array { 12 | func chunked(into size: Int) -> [[Element]] { 13 | return stride(from: 0, to: count, by: size).map { 14 | Array(self[$0 ..< Swift.min($0 + size, count)]) 15 | } 16 | } 17 | 18 | subscript (safe index: Index) -> Element { 19 | 0 <= index && index < count ? self[index] : self[startIndex] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Features/Sources/MangaScraper/Extension/Date.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date.swift 3 | // Hanako 4 | // 5 | // Created by Stephan Deumier on 07/01/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Date { 11 | static func from(year: Int, month: Int, day: Int) -> Date { 12 | var dateComponents = DateComponents() 13 | dateComponents.year = year 14 | dateComponents.month = month 15 | dateComponents.day = day 16 | 17 | return Calendar(identifier: .gregorian).date(from: dateComponents)! 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Features/Sources/MangaScraper/Extension/String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 02/07/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String: @retroactive Error {} 11 | -------------------------------------------------------------------------------- /Features/Sources/MangaScraper/MangaScraperService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class MangaScraperService { 4 | nonisolated(unsafe) public static let shared = MangaScraperService() 5 | 6 | public var list: [Source] = [ 7 | NepNepSource.MangaSee123Source, 8 | NepNepSource.Manga4LifeSource, 9 | MangaDex.shared 10 | ] 11 | 12 | public func getSource(sourceId: UUID) -> Source? { 13 | return list.first { $0.id == sourceId } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Features/Sources/MangaScraper/Sources/Source.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaSource.swift 3 | // Hanako 4 | // 5 | // Created by Stephan Deumier on 30/12/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum SourceError: Error { 11 | case parseError(error: String) 12 | case websiteError 13 | case fetchError 14 | case notImplemented 15 | } 16 | 17 | public enum SourceLang: String, CaseIterable, Sendable { 18 | case fr = "French" 19 | case en = "English" 20 | case jp = "Japanese" 21 | case all = "All" 22 | } 23 | 24 | public enum SourceMangaCompletion: String, CaseIterable, Sendable { 25 | case ongoing = "Ongoing" 26 | case complete = "Complete" 27 | case unknown = "Unknown" 28 | } 29 | 30 | public enum SourceMangaType: String, CaseIterable, Sendable { 31 | case manga = "Manga" 32 | case manhua = "Manhua" 33 | case manhwa = "Manhwa" 34 | case doujinshi = "Doujinshi" 35 | case unknown = "Unknown" 36 | } 37 | 38 | public struct SourceManga: Identifiable, Equatable, Hashable, Sendable { 39 | public var id: String 40 | public var title: String 41 | public var cover: String 42 | public var genres: [String] 43 | public var authors: [String] 44 | public var alternateNames: [String] 45 | public var status: SourceMangaCompletion 46 | public var synopsis: String 47 | public var chapters: [SourceChapter] 48 | public var type: SourceMangaType 49 | } 50 | 51 | public struct SourceChapter: Identifiable, Equatable, Hashable, Sendable { 52 | public var name: String 53 | public var id: String 54 | public var dateUpload: Date 55 | public var externalUrl: String? 56 | } 57 | 58 | public struct SourceChapterImage: Identifiable, Equatable, Hashable, Sendable { 59 | public var id = UUID() 60 | 61 | public var index: Int 62 | public var imageUrl: String 63 | 64 | public init(index: Int, imageUrl: String) { 65 | self.index = index 66 | self.imageUrl = imageUrl 67 | } 68 | } 69 | 70 | public struct SourceSmallManga: Identifiable, Equatable, Hashable, Sendable { 71 | public init(id: String, title: String, thumbnailUrl: String) { 72 | self.id = id 73 | self.title = title 74 | self.thumbnailUrl = thumbnailUrl 75 | } 76 | 77 | public var id: String 78 | public var title: String 79 | public var thumbnailUrl: String 80 | } 81 | 82 | public enum SourceFetchType: String, CaseIterable, Identifiable, Sendable { 83 | case latest = "Latest" 84 | case popular = "Popular" 85 | 86 | public var id: Self { self } 87 | } 88 | 89 | public typealias SourcePaginatedSmallManga = (mangas: [SourceSmallManga], hasNextPage: Bool) 90 | 91 | public protocol Source: Sendable { 92 | var name: String { get } 93 | var id: UUID { get } 94 | var versionNumber: Float { get } 95 | var updatedAt: Date { get } 96 | var lang: SourceLang { get } 97 | var icon: String { get } 98 | var baseUrl: String { get } 99 | var supportsLatest: Bool { get } 100 | var headers: [String:String] { get } 101 | var nsfw: Bool { get } 102 | 103 | func fetchPopularManga(page: Int) async throws -> SourcePaginatedSmallManga 104 | func fetchLatestUpdates(page: Int) async throws -> SourcePaginatedSmallManga 105 | func fetchSearchManga(query: String, page: Int) async throws -> SourcePaginatedSmallManga 106 | func fetchMangaDetail(id: String) async throws -> SourceManga 107 | func fetchChapterImages(mangaId: String, chapterId: String) async throws -> [SourceChapterImage] 108 | func mangaUrl(mangaId: String) -> URL 109 | func checkUpdates(mangaIds: [String]) async throws -> Void 110 | } 111 | 112 | public protocol MultiSource: Source { 113 | init(baseUrl: String, icon: String, id: UUID, name: String) 114 | } 115 | 116 | extension Identifiable where Self: Source {} 117 | -------------------------------------------------------------------------------- /Features/Sources/Reader/Components/ChapterImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshableImageView.swift 3 | // Dokusho (iOS) 4 | // 5 | // Created by Stephan Deumier on 22/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import Nuke 10 | import NukeUI 11 | import Common 12 | 13 | struct ChapterImageView: View { 14 | private let fullHeight = UIScreen.main.bounds.height 15 | 16 | @StateObject private var image: FetchImage 17 | @State var id = UUID() 18 | @Binding var isZooming: Bool 19 | 20 | let url: URL 21 | let contentMode: ContentMode 22 | 23 | init(url: URL?, contentMode: ContentMode, isZooming: Binding) { 24 | self.url = url ?? URL(string: "https://picsum.photos/seed/picsum/200/300")! 25 | self.contentMode = contentMode 26 | self._isZooming = isZooming 27 | 28 | let image = FetchImage() 29 | image.pipeline = ImagePipeline.inMemory 30 | _image = .init(wrappedValue: image) 31 | } 32 | 33 | init(url: String?, contentMode: ContentMode, isZooming: Binding) { 34 | let url = URL(string: url ?? "") ?? URL(string: "https://picsum.photos/seed/picsum/200/300")! 35 | self.init(url: url, contentMode: contentMode, isZooming: isZooming) 36 | } 37 | 38 | var body: some View { 39 | Group { 40 | switch image.result { 41 | case .success(let res): 42 | Image(uiImage: res.image) 43 | .resizable() 44 | .aspectRatio(contentMode: contentMode) 45 | .contextMenu { ContextMenu(image: res.image) } 46 | .addPinchAndPan(isZooming: $isZooming) 47 | case .failure(let err): 48 | VStack { 49 | Button(action: { id = UUID() }) { 50 | Image(systemName: "arrow.clockwise") 51 | .resizable() 52 | .frame(width: 32, height: 32) 53 | } 54 | 55 | Text("Error: \(err.localizedDescription)") 56 | } 57 | .frame(height: fullHeight) 58 | default: 59 | ProgressView() 60 | .scaleEffect(3) 61 | .frame(height: fullHeight) 62 | } 63 | } 64 | .id(id) 65 | .onAppear(perform: onAppear) 66 | .onDisappear(perform: onDisappear) 67 | } 68 | 69 | @ViewBuilder 70 | func ContextMenu(image: UIImage) -> some View { 71 | Button(action: { saveImage(image: image) }) { 72 | Label("Save to library", systemImage: "icloud.and.arrow.down") 73 | } 74 | } 75 | 76 | func onAppear() { 77 | image.priority = .high 78 | image.load(url) 79 | } 80 | 81 | func onDisappear() { 82 | image.priority = .low 83 | } 84 | 85 | func saveImage(image: UIImage) { 86 | UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Features/Sources/Reader/Components/HorizontalReaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HorizontalReaderView.swift 3 | // Dokusho (iOS) 4 | // 5 | // Created by Stephan Deumier on 15/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import Common 10 | 11 | struct HorizontalReaderView: View { 12 | @ObservedObject var vm: ReaderVM 13 | @State var isZooming = false 14 | 15 | var body: some View { 16 | TabView(selection: $vm.tabIndex) { 17 | ForEach(vm.getImagesOrderForDirection(), id: \.self) { image in 18 | GeometryReader { proxy in 19 | ReaderLinkRender(image: image, proxy: proxy) 20 | } 21 | } 22 | } 23 | .tabViewStyle(.page(indexDisplayMode: .never)) 24 | .id(vm.images) 25 | } 26 | 27 | @ViewBuilder 28 | func ReaderLinkRender(image: ReaderLink, proxy: GeometryProxy) -> some View { 29 | Group { 30 | switch image { 31 | case .image(let url): 32 | ImageView(image: url, proxy: proxy) 33 | case .previous(let chapter): 34 | DirectionView(title: "Previous chapter \(chapter.title)", direction: .previous, proxy: proxy) 35 | case .next(let chapter): 36 | DirectionView(title: "Next chapter \(chapter.title)", direction: .next, proxy: proxy) 37 | } 38 | } 39 | .id(image) 40 | .tag(image) 41 | } 42 | 43 | func ImageView(image: String, proxy: GeometryProxy) -> some View { 44 | ChapterImageView(url: image, contentMode: .fit, isZooming: $isZooming) 45 | .frame( 46 | minWidth: UIScreen.isLargeScreen ? proxy.size.width / 2 : proxy.size.width, 47 | minHeight: proxy.size.height, 48 | alignment: .center 49 | ) 50 | } 51 | 52 | func DirectionView(title: String, direction: GoToChapterDirection, proxy: GeometryProxy) -> some View { 53 | Rectangle() 54 | .fill(.black) 55 | .frame( 56 | minWidth: UIScreen.isLargeScreen ? proxy.size.width / 2 : proxy.size.width, 57 | minHeight: proxy.size.height, 58 | alignment: .center 59 | ) 60 | .overlay(alignment: .center) { 61 | Text(title) 62 | } 63 | .onTapGesture(count: 3) { vm.goToChapter(direction) } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Features/Sources/Reader/Components/VerticalReaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerticalReaderView.swift 3 | // Dokusho (iOS) 4 | // 5 | // Created by Stephan Deumier on 16/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import Common 10 | 11 | struct VerticalReaderView: View { 12 | @ObservedObject var vm: ReaderVM 13 | 14 | var body: some View { 15 | GeometryReader { proxy in 16 | ScrollViewReader { scrollProxy in 17 | ScrollView([.vertical], showsIndicators: false) { 18 | LazyVStack(spacing: 0) { 19 | ForEach(vm.getImagesOrderForDirection(), id: \.self) { image in 20 | ReaderLinkRender(image: image, proxy: proxy) 21 | } 22 | } 23 | } 24 | .id(vm.images) 25 | } 26 | } 27 | } 28 | 29 | @ViewBuilder 30 | func ReaderLinkRender(image: ReaderLink, proxy: GeometryProxy) -> some View { 31 | Group { 32 | switch image { 33 | case .image(let url): 34 | ImageView(image: url, proxy: proxy) 35 | case .previous(let chapter): 36 | DirectionView(title: "Previous chapter \(chapter.title)", direction: .previous, size: .init(width: proxy.size.width, height: 50)) 37 | case .next(let chapter): 38 | DirectionView(title: "Next chapter \(chapter.title)", direction: .next, size: .init(width: proxy.size.width, height: 50)) 39 | } 40 | } 41 | .id(image) 42 | .tag(image) 43 | .onAppear { vm.updateTabIndex(image: image) } 44 | } 45 | 46 | func ImageView(image: String, proxy: GeometryProxy) -> some View { 47 | ChapterImageView(url: image, contentMode: .fit, isZooming: .constant(false)) 48 | .frame( 49 | width: UIScreen.isLargeScreen ? proxy.size.width / 2 : proxy.size.width, 50 | alignment: .center 51 | ) 52 | } 53 | 54 | func DirectionView(title: String, direction: GoToChapterDirection, size: CGSize) -> some View { 55 | Rectangle() 56 | .fill(.black) 57 | .frame( 58 | minWidth: UIScreen.isLargeScreen ? size.width / 2 : size.width, 59 | minHeight: size.height, 60 | alignment: .center 61 | ) 62 | .overlay(alignment: .center) { 63 | Text(title) 64 | } 65 | .onTapGesture(count: 3) { vm.goToChapter(direction) } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Features/Sources/Reader/ReaderManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReaderManager.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 20/04/2022. 6 | // 7 | 8 | import Foundation 9 | import DataKit 10 | import SwiftUI 11 | 12 | public class ReaderManager: ObservableObject { 13 | public struct SelectedChapter: Identifiable { 14 | public var chapter: MangaChapter 15 | public var manga: Manga 16 | public var scraper: Scraper 17 | public var chapters: [MangaChapter] 18 | 19 | public var id: String { chapter.id } 20 | } 21 | 22 | public init() {} 23 | 24 | private var database = AppDatabase.shared.database 25 | 26 | @Published public var selectedChapter: SelectedChapter? 27 | 28 | public func selectChapter(chapter: MangaChapter, manga: Manga, scraper: Scraper, chapters: [MangaChapter]) { 29 | self.selectedChapter = .init(chapter: chapter, manga: manga, scraper: scraper, chapters: chapters) 30 | } 31 | 32 | public func dismiss() { 33 | withAnimation { 34 | self.selectedChapter = nil 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Features/Sources/Reader/ReaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReaderView.swift 3 | // Dokusho (iOS) 4 | // 5 | // Created by Stephan Deumier on 15/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import DataKit 10 | import Common 11 | 12 | typealias OnProgress = (_ status: ChapterStatus) -> Void 13 | 14 | public struct ReaderView: View { 15 | @Environment(\.dismiss) var dismiss 16 | 17 | @StateObject public var vm: ReaderVM 18 | @ObservedObject public var readerManager: ReaderManager 19 | @Namespace private var overlayAnimation 20 | 21 | public init(vm: ReaderVM, readerManager: ReaderManager) { 22 | _vm = .init(wrappedValue: vm) 23 | _readerManager = .init(wrappedValue: readerManager) 24 | } 25 | 26 | public var body: some View { 27 | Group { 28 | if vm.isLoading { 29 | ProgressView() 30 | .scaleEffect(3) 31 | } else { 32 | if vm.direction == .vertical { VerticalReaderView(vm: vm) } 33 | else { HorizontalReaderView(vm: vm) } 34 | } 35 | } 36 | .frame(maxWidth: .infinity, maxHeight: .infinity) 37 | .background(Color.black.ignoresSafeArea()) 38 | .navigationBarHidden(true) 39 | .onTapGesture { vm.toggleToolbar() } 40 | .task(id: vm.currentChapter) { await vm.fetchChapter() } 41 | .task(id: vm.tabIndex) { await vm.updateChapterStatus() } 42 | .statusBar(hidden: !vm.showToolBar) 43 | .overlay(alignment: .top) { TopOverlay() } 44 | .overlay(alignment: .bottom) { BottomOverlay() } 45 | .preferredColorScheme(.dark) 46 | .onAppear { 47 | UIApplication.shared.isIdleTimerDisabled = true 48 | vm.cancelTasks() 49 | } 50 | .onDisappear { 51 | UIApplication.shared.isIdleTimerDisabled = false 52 | vm.cancelTasks() 53 | } 54 | .task(id: vm.tabIndex) { 55 | await vm.backgroundFetchImage() 56 | } 57 | } 58 | 59 | @ViewBuilder 60 | func BottomOverlay() -> some View { 61 | if vm.showToolBar { 62 | VStack(alignment: .center, spacing: 1) { 63 | HStack(alignment: .center) { 64 | Button(action: { vm.goToChapter(.previous) }) { 65 | Image(systemName: "chevron.left") 66 | .padding(.trailing) 67 | } 68 | .disabled(!vm.hasPreviousChapter()) 69 | 70 | Spacer() 71 | 72 | if !vm.images.isEmpty { 73 | VStack { 74 | // TODO: Add a custom slider to be able to update tabIndex value 75 | ProgressView(value: vm.progressBarCurrent(), total: vm.progressBarCount()) 76 | .rotationEffect(.degrees(vm.direction == .rightToLeft ? 180 : 0)) 77 | } 78 | .frame(height: 25) 79 | } 80 | 81 | Spacer() 82 | 83 | Button(action: { vm.goToChapter(.next) }) { 84 | Image(systemName: "chevron.right") 85 | .padding(.leading) 86 | } 87 | .disabled(!vm.hasNextChapter()) 88 | } 89 | 90 | if !vm.images.isEmpty { 91 | Text("\(Int(vm.progressBarCurrent())) of \(Int(vm.progressBarCount()))") 92 | .padding(.leading) 93 | .font(.footnote.italic()) 94 | } 95 | } 96 | .padding([.horizontal, .top]) 97 | .background(.thickMaterial) 98 | .offset(x: 0, y: vm.showToolBar ? 0 : 500) 99 | .transition(.move(edge: vm.showToolBar ? .bottom : .top)) 100 | } else { 101 | if !vm.images.isEmpty { 102 | Text("\(Int(vm.progressBarCurrent())) / \(Int(vm.progressBarCount()))") 103 | .transition(.move(edge: !vm.showToolBar ? .bottom : .top)) 104 | .glowBorder(color: .black, lineWidth: 3) 105 | .font(.footnote.italic()) 106 | } 107 | } 108 | } 109 | 110 | @ViewBuilder 111 | func TopOverlay() -> some View { 112 | if vm.showToolBar { 113 | Group { 114 | HStack(alignment: .center) { 115 | Button(action: dismiss.callAsFunction) { 116 | Image(systemName: "xmark") 117 | } 118 | 119 | Spacer() 120 | 121 | VStack(alignment: .center, spacing: 0) { 122 | Text(vm.manga.title) 123 | .font(.subheadline) 124 | .allowsTightening(true) 125 | .lineLimit(1) 126 | .foregroundColor(.primary) 127 | Text(vm.currentChapter.title) 128 | .font(.subheadline) 129 | .italic() 130 | .allowsTightening(true) 131 | .lineLimit(1) 132 | .foregroundColor(.primary) 133 | } 134 | 135 | Spacer() 136 | 137 | Menu { 138 | Menu("Chapters") { 139 | ForEach(vm.getChapters()) { chapter in 140 | Button(action: { vm.goToChapter(to: chapter) }) { 141 | SelectedMenuItem(text: chapter.title, comparaison: vm.currentChapter == chapter) 142 | } 143 | } 144 | } 145 | 146 | Menu("Reader direction") { 147 | ForEach(ReadingDirection.allCases, id: \.self) { direction in 148 | Button(action: { vm.setReadingDirection(new: direction) }) { 149 | SelectedMenuItem(text: direction.rawValue, comparaison: vm.direction == direction) 150 | } 151 | } 152 | } 153 | } label: { 154 | Image(systemName: "slider.horizontal.3") 155 | } 156 | } 157 | } 158 | .padding(.all) 159 | .offset(x: 0, y: vm.showToolBar ? 0 : -150) 160 | .background(.thickMaterial) 161 | .transition(.move(edge: vm.showToolBar ? .top : .bottom)) 162 | } 163 | } 164 | 165 | @ViewBuilder 166 | func SelectedMenuItem(text: String, comparaison: Bool, systemImage: String = "checkmark") -> some View { 167 | if comparaison && !systemImage.isEmpty { Label(text, systemImage: systemImage) } 168 | else { Text(text) } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /Features/Sources/SettingsTab/SettingsTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // Dokusho (iOS) 4 | // 5 | // Created by Stephan Deumier on 28/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import UniformTypeIdentifiers 10 | import Common 11 | 12 | public struct SettingsTabView: View { 13 | @StateObject var vm = SettingsVM() 14 | @Preference(\.useNewHorizontalReader) var userNewHorizontalReader 15 | @Preference(\.useNewVerticalReader) var useNewVerticalReader 16 | @Preference(\.onlyUpdateAllRead) var onlyUpdateAllRead 17 | @Preference(\.numberOfPreloadedImages) var numberOfPreloadedImages 18 | 19 | public init() {} 20 | 21 | public var body: some View { 22 | NavigationView { 23 | List { 24 | Section("Data") { 25 | Button(action: { vm.createBackup() }) { 26 | Text("Create Backup") 27 | } 28 | Button(action: { vm.showImportfile.toggle() }) { 29 | Text("Import Backup") 30 | } 31 | Button(action: { vm.cleanOrphanData() }) { 32 | Text("Clean orphan data") 33 | } 34 | Stepper("\(numberOfPreloadedImages) preloaded images", value: $numberOfPreloadedImages, in: 3...6, step: 1) 35 | } 36 | 37 | Section("Experimental") { 38 | Toggle("Use new horizontal reader", isOn: $userNewHorizontalReader) 39 | Toggle("Use new vertical reader", isOn: $useNewVerticalReader) 40 | } 41 | 42 | Section("Cache") { 43 | Button(action: { vm.clearImageCache() }) { 44 | Text("Clear image cache") 45 | } 46 | } 47 | 48 | Section("Collection Update") { 49 | Toggle("Only update when manga has no unread chapter", isOn: $onlyUpdateAllRead) 50 | } 51 | } 52 | .fileExporter(isPresented: $vm.showExportfile, document: vm.file, contentType: .json, defaultFilename: vm.fileName) { res in 53 | vm.showExportfile.toggle() 54 | } 55 | .fileImporter(isPresented: $vm.showImportfile, allowedContentTypes: [.json], allowsMultipleSelection: false) { res in 56 | let url = try! res.get().first! 57 | Task { await vm.importBackup(url: url) } 58 | } 59 | .overlay { 60 | if vm.actionInProgress { 61 | ZStack { 62 | ProgressView() 63 | .scaleEffect(2) 64 | } 65 | .zIndex(1000) 66 | .ignoresSafeArea(.all) 67 | .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height) 68 | .background(.ultraThinMaterial) 69 | } 70 | } 71 | .navigationTitle("Settings") 72 | .navigationBarTitleDisplayMode(.large) 73 | } 74 | .navigationViewStyle(.stack) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Features/Sources/SettingsTab/SettingsVM.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsVM.swift 3 | // Dokusho (iOS) 4 | // 5 | // Created by Stephan Deumier on 28/06/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Nuke 11 | import DataKit 12 | import Backup 13 | 14 | class SettingsVM: ObservableObject { 15 | @Published var actionInProgress = false 16 | @Published var showExportfile = false 17 | @Published var file: Backup? 18 | @Published var fileName: String? 19 | @Published var showImportfile = false 20 | 21 | @MainActor 22 | func createBackup() { 23 | actionInProgress.toggle() 24 | 25 | let backup = BackupManager.shared.createBackup() 26 | 27 | fileName = "dokusho-backup-\(Date.now.ISO8601Format()).json" 28 | file = Backup(data: backup) 29 | 30 | showExportfile.toggle() 31 | actionInProgress.toggle() 32 | } 33 | 34 | @MainActor 35 | func importBackup(url: URL) async { 36 | do { 37 | CFURLStartAccessingSecurityScopedResource(url as CFURL) 38 | actionInProgress.toggle() 39 | let data = try Data(contentsOf: url) 40 | let backup = try JSONDecoder().decode(BackupData.self, from: data) 41 | CFURLStopAccessingSecurityScopedResource(url as CFURL) 42 | 43 | await BackupManager.shared.importBackup(backup: backup) 44 | 45 | self.actionInProgress.toggle() 46 | } catch { 47 | print(error) 48 | self.actionInProgress.toggle() 49 | } 50 | } 51 | 52 | @MainActor 53 | func cleanOrphanData() {} 54 | 55 | func clearImageCache() { 56 | Nuke.DataLoader.sharedUrlCache.removeAllCachedResponses() 57 | Nuke.ImageCache.shared.removeAll() 58 | DataCache.DiskCover?.removeAll() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Features/Sources/SharedUI/Components/AdaptiveStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SidebarView.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 22/09/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct AdaptiveStack: View { 11 | @Environment(\.horizontalSizeClass) var sizeClass 12 | 13 | let horizontalAlignment: HorizontalAlignment 14 | let verticalAlignment: VerticalAlignment 15 | let spacing: CGFloat? 16 | let content: () -> Content 17 | 18 | public init(horizontalAlignment: HorizontalAlignment = .center, verticalAlignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: @escaping () -> Content) { 19 | self.horizontalAlignment = horizontalAlignment 20 | self.verticalAlignment = verticalAlignment 21 | self.spacing = spacing 22 | self.content = content 23 | } 24 | 25 | public var body: some View { 26 | Group { 27 | if sizeClass == .compact { 28 | VStack(alignment: horizontalAlignment, spacing: spacing, content: content) 29 | } else { 30 | HStack(alignment: verticalAlignment, spacing: spacing, content: content) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Features/Sources/SharedUI/Components/AsyncButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncButton.swift 3 | // AsyncButton 4 | // 5 | // Created by Stephan Deumier on 23/07/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct AsyncButton: View { 11 | @State var isActionRunning = false 12 | 13 | let action: @Sendable () async throws -> Void 14 | let content: Content 15 | 16 | public init(action: @Sendable @escaping () async -> Void, @ViewBuilder _ content: () -> Content) { 17 | self.action = action 18 | self.content = content() 19 | } 20 | 21 | public var body: some View { 22 | Button(action: { 23 | Task { 24 | self.isActionRunning.toggle() 25 | try await self.action() 26 | self.isActionRunning.toggle() 27 | } 28 | }) { 29 | if isActionRunning { ProgressView() } 30 | else { self.content } 31 | } 32 | .disabled(isActionRunning) 33 | .buttonStyle(.plain) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Features/Sources/SharedUI/Components/DebouncedSearchBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Stef on 11/05/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct DebouncedSearchBar: View { 11 | @StateObject private var textObserver = FieldObserver() 12 | 13 | @Binding var debouncedText: String 14 | @Binding var isFocused: Bool 15 | 16 | var disableAutoCorrect = true 17 | 18 | public init(debouncedText: Binding, isFocused: Binding) { 19 | _debouncedText = debouncedText 20 | _isFocused = isFocused 21 | } 22 | 23 | public var body: some View { 24 | VStack { 25 | TextField("Title", text: $textObserver.searchText) 26 | .disableAutocorrection(disableAutoCorrect) 27 | }.onReceive(textObserver.$debouncedText) { (val) in 28 | debouncedText = val 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Features/Sources/SharedUI/Components/DebouncedTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Stef on 11/05/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct DebouncedTextField: View { 11 | @Binding var debouncedText : String 12 | @StateObject private var textObserver = FieldObserver() 13 | 14 | public init(debouncedText: Binding) { 15 | _debouncedText = debouncedText 16 | } 17 | 18 | public var body: some View { 19 | VStack { 20 | TextField("Enter Something", text: $textObserver.searchText) 21 | .frame(height: 50) 22 | .padding(.leading, 5) 23 | .overlay( 24 | RoundedRectangle(cornerRadius: 6) 25 | .stroke(Color.blue, lineWidth: 1) 26 | ) 27 | .padding(.horizontal, 20) 28 | }.onReceive(textObserver.$debouncedText) { (val) in 29 | debouncedText = val 30 | } 31 | }} 32 | 33 | -------------------------------------------------------------------------------- /Features/Sources/SharedUI/Components/FlexibleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlexibleView.swift 3 | // Dokusho (iOS) 4 | // 5 | // Created by Stephan Deumier on 14/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | import Common 10 | 11 | public struct FlexibleView: View where Data.Element: Hashable { 12 | let data: Data 13 | let availableWidth: CGFloat 14 | let spacing: CGFloat 15 | let alignment: HorizontalAlignment 16 | let content: (Data.Element) -> Content 17 | 18 | @State var elementsSize: [Data.Element: CGSize] = [:] 19 | 20 | public init(data: Data, availableWidth: CGFloat, spacing: CGFloat, alignment: HorizontalAlignment, @ViewBuilder content: @escaping (Data.Element) -> Content) { 21 | self.data = data 22 | self.availableWidth = availableWidth 23 | self.spacing = spacing 24 | self.alignment = alignment 25 | self.content = content 26 | } 27 | 28 | public var body : some View { 29 | VStack(alignment: alignment, spacing: spacing) { 30 | ForEach(computeRows(), id: \.self) { rowElements in 31 | HStack(spacing: spacing) { 32 | ForEach(rowElements, id: \.self) { element in 33 | content(element) 34 | .fixedSize() 35 | .readSize { elementsSize[element] = $0 } 36 | } 37 | } 38 | } 39 | } 40 | } 41 | 42 | func computeRows() -> [[Data.Element]] { 43 | var rows: [[Data.Element]] = [[]] 44 | var currentRow = 0 45 | var remainingWidth = availableWidth 46 | 47 | for element in data { 48 | let elementSize = elementsSize[element, default: CGSize(width: availableWidth, height: 1)] 49 | 50 | if remainingWidth - (elementSize.width + spacing) >= 0 { 51 | rows[currentRow].append(element) 52 | } else { 53 | currentRow = currentRow + 1 54 | rows.append([element]) 55 | remainingWidth = availableWidth 56 | } 57 | 58 | remainingWidth = remainingWidth - (elementSize.width + spacing) 59 | } 60 | 61 | return rows 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Features/Sources/SharedUI/Components/LibraryRefresher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryRefresher.swift 3 | // Dokusho 4 | // 5 | // Created by Stef on 20/04/2022. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public struct LibraryRefresher: View { 12 | var title: String 13 | var progress: Double 14 | var total: Double 15 | 16 | public init(title: String, progress: Double, total: Double) { 17 | self.title = title 18 | self.progress = progress 19 | self.total = total 20 | } 21 | 22 | public var body: some View { 23 | VStack { 24 | Text(title) 25 | .lineLimit(1) 26 | .padding(.horizontal, 10) 27 | .padding(.top, 15) 28 | ProgressView(value: progress, total: total) 29 | .padding(.horizontal, 10) 30 | .padding(.bottom, 2) 31 | } 32 | .background(.ultraThickMaterial) 33 | .clipShape(Rectangle()) 34 | .cornerRadius(15) 35 | .padding(.horizontal, 50) 36 | .padding(.bottom, 55) 37 | .shadow(radius: 5) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Features/Sources/SharedUI/Components/MangaCard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Stephan Deumier on 26/05/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct MangaCard: View { 11 | var imageUrl: String 12 | 13 | var title: String? 14 | var chapterCount: Int? 15 | var collectionName: String? 16 | 17 | var radius: Double = 5 18 | var opacity: Double = 0.83 19 | 20 | public init(title: String, imageUrl: String, chapterCount: Int) { 21 | self.title = title 22 | self.chapterCount = chapterCount 23 | self.imageUrl = imageUrl 24 | } 25 | 26 | public init(title: String, imageUrl: String, collectionName: String) { 27 | self.title = title 28 | self.collectionName = collectionName 29 | self.imageUrl = imageUrl 30 | } 31 | 32 | public init(imageUrl: String, chapterCount: Int) { 33 | self.imageUrl = imageUrl 34 | self.chapterCount = chapterCount 35 | } 36 | 37 | public init(imageUrl: String) { 38 | self.imageUrl = imageUrl 39 | } 40 | 41 | public var body: some View { 42 | RemoteImageCacheView(url: self.imageUrl, contentMode: .fill) 43 | .clipShape(RoundedCorner(radius: radius, corners: [.allCorners])) 44 | .overlay(alignment: .topTrailing) { ChapterCounter() } 45 | .overlay(alignment: .topLeading) { CollectionName() } 46 | .overlay(alignment: .bottomLeading) { Title() } 47 | } 48 | 49 | @ViewBuilder 50 | func Title() -> some View { 51 | if let title = title, !title.isEmpty { 52 | VStack { 53 | Text(title) 54 | .lineLimit(2) 55 | .multilineTextAlignment(.leading) 56 | .clipped() 57 | .padding(.leading, 2) 58 | .padding(.top, 1) 59 | .frame(maxWidth: .infinity, alignment: .center) 60 | .foregroundColor(.white) 61 | .background(.gray.opacity(opacity), in: RoundedCorner(radius: radius, corners: [.allCorners])) 62 | } 63 | .padding(2) 64 | } 65 | } 66 | 67 | @ViewBuilder 68 | func ChapterCounter() -> some View { 69 | if let count = chapterCount, count != 0 { 70 | VStack { 71 | Text(String(count)) 72 | .padding(2) 73 | .foregroundColor(.white) 74 | .background(.gray.opacity(opacity), in: RoundedCorner(radius: radius, corners: [.allCorners])) 75 | } 76 | .padding(2) 77 | } 78 | } 79 | 80 | @ViewBuilder 81 | func CollectionName() -> some View { 82 | if let collectionName = collectionName, !collectionName.isEmpty { 83 | VStack { 84 | Text(collectionName) 85 | .lineLimit(1) 86 | .padding(2) 87 | .foregroundColor(.white) 88 | .background(.gray.opacity(opacity), in: RoundedCorner(radius: radius, corners: [.allCorners]) ) 89 | } 90 | .padding(2) 91 | } 92 | } 93 | } 94 | 95 | public extension View { 96 | func mangaCardFrame(width: Double = 130, height: Double = 180) -> some View { 97 | if width != 130 || height != 180 { 98 | return self 99 | .frame(width: width, height: height) 100 | } else if UIScreen.isLargeScreen { 101 | return self 102 | .frame(width: 130*1.3, height: 180*1.3) 103 | } else { 104 | return self 105 | .frame(width: 130, height: 180) 106 | } 107 | } 108 | } 109 | 110 | struct SwiftUIView_Previews: PreviewProvider { 111 | static var previews: some View { 112 | MangaCard(title: "Ookii Kouhai wa Suki Desu ka", imageUrl: "https://cover.nep.li/cover/Ookii-Kouhai-wa-Suki-Desu-ka.jpg", collectionName: "Reading") 113 | .mangaCardFrame() 114 | 115 | MangaCard(title: "Ookii Kouhai wa Suki Desu ka", imageUrl: "https://cover.nep.li/cover/Ookii-Kouhai-wa-Suki-Desu-ka.jpg", chapterCount: 5) 116 | .mangaCardFrame() 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Features/Sources/SharedUI/Components/MangaList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Stephan Deumier on 04/06/2022. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public struct MangaList>: View { 12 | var mangas: Y 13 | var mangaContent: (T) -> MangaContent 14 | 15 | var columns: [GridItem] { 16 | let size: Double = UIScreen.isLargeScreen ? 130*1.3 : 130 17 | return [GridItem(.adaptive(minimum: size))] 18 | } 19 | 20 | public init(mangas: Y, @ViewBuilder mangaRender: @escaping (T) -> MangaContent) { 21 | self.mangas = mangas 22 | self.mangaContent = mangaRender 23 | } 24 | 25 | public var body: some View { 26 | LazyVGrid(columns: columns) { 27 | ForEach(mangas) { data in 28 | mangaContent(data) 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Features/Sources/SharedUI/Components/RemoteImageCacheView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageCacheView.swift 3 | // Dokusho 4 | // 5 | // Created by Stephan Deumier on 06/07/2021. 6 | // 7 | 8 | import SwiftUI 9 | import Nuke 10 | import NukeUI 11 | import Common 12 | 13 | public struct RemoteImageCacheView: View { 14 | let url: String 15 | let contentMode: ImageResizingMode 16 | let pipeline: ImagePipeline 17 | 18 | public init(url: String?, contentMode: ImageResizingMode, pipeline: ImagePipeline = .coverCache) { 19 | self.url = url ?? "https://picsum.photos/seed/picsum/200/300" 20 | self.contentMode = contentMode 21 | self.pipeline = pipeline 22 | } 23 | 24 | // ImageProcessors.RoundedCorners(radius: radius, border: .init(color: .gray, width: 0.2)) 25 | public var body: some View { 26 | GeometryReader { proxy in 27 | LazyImage(request: url.asImageRequest(), resizingMode: contentMode) 28 | .processors([ImageProcessors.Resize(size: proxy.size)]) 29 | .pipeline(pipeline) 30 | .id(url) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Features/Sources/SharedUI/Components/Toolbar/AddButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddButton.swift 3 | // AddButton 4 | // 5 | // Created by Stephan Deumier on 08/09/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct AddButton: View { 11 | let onTapGesture: @Sendable () -> Void 12 | 13 | public init(onTapGesture: @Sendable @escaping () -> Void) { 14 | self.onTapGesture = onTapGesture 15 | } 16 | 17 | public var body: some View { 18 | AsyncButton(action: onTapGesture) { 19 | Image(systemName: "plus") 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Features/Sources/SharedUI/ObservableObject/FieldObserver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FieldObserver.swift 3 | // 4 | // 5 | // Created by Stef on 11/05/2022. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Combine 11 | 12 | public class FieldObserver : ObservableObject { 13 | @Published var debouncedText = "" 14 | @Published var searchText = "" 15 | 16 | private var subscriptions = Set() 17 | 18 | public init() { 19 | $searchText 20 | .debounce(for: .seconds(1), scheduler: DispatchQueue.main) 21 | .sink(receiveValue: { [weak self] t in 22 | self?.debouncedText = t 23 | }) 24 | .store(in: &subscriptions) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Features/Sources/SharedUI/Shapes/RoundedCorner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoundedCorner.swift 3 | // Dokusho (iOS) 4 | // 5 | // Created by Stephan Deumier on 26/06/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public struct RoundedCorner: Shape { 12 | var radius: CGFloat = .infinity 13 | var corners: UIRectCorner = .allCorners 14 | 15 | public init(radius: CGFloat = .infinity, corners: UIRectCorner = .allCorners) { 16 | self.radius = radius 17 | self.corners = corners 18 | } 19 | 20 | public func path(in rect: CGRect) -> Path { 21 | let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) 22 | return Path(path.cgPath) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Features/Tests/MangaScraperTests/MangaScraperTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import MangaScraper 3 | 4 | final class MangaScraperTests: XCTestCase { 5 | func testExample() async throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(MangaScraperService.shared.getSource(sourceId: UUID(uuidString: "FFAECF22-DBB3-4B13-B4AF-665DC31CE775")!)?.id, NepNepSource.MangaSee123Source.id) 10 | XCTAssertEqual(MangaScraperService.shared.getSource(sourceId: UUID(uuidString: "B6127CD7-A9C0-4610-8491-47DFCFD90DBC")!)?.id, NepNepSource.Manga4LifeSource.id) 11 | XCTAssertEqual(MangaScraperService.shared.getSource(sourceId: UUID(uuidString: "3599756d-8fa0-4ca2-aafc-096c3d776ae1")!)?.id, MangaDex.shared.id) 12 | do { 13 | let d = try await MangaDex.shared.fetchMangaDetail(id: "38f386ce-f2dc-4f2b-99f9-522eab56078d") 14 | print(d.title) 15 | print(d.chapters) 16 | } catch { 17 | print(error) 18 | } 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dokusho 2 | 3 | Kind of tired to need an Android Device on me, just to read manga, so here we are. 4 | 5 | 6 | I am going to prioritize feature based on how I feel and not people expectation, you know the saying if you want something do it yourself (or pay me/someone to do it if you don't know how). 7 | 8 | 9 | Also this is my first iOS App and first time with Swift/SwiftUI so code is a mess, sorry about this, I will try to fix it once I am more skilled in Swift 10 | 11 | 12 | It's a OSS project (see license) so unless your code is a bigger mess than mine and/or doesn't work I see no reason to refuse your PR, even if that's the case you can still fix it, so don't hesitate to make a PR 13 | 14 | Alternative to consider: 15 | - ~~MangaSoup, v2 should be the best one for iOS I think~~ (sorry dead...) 16 | - [Aidoku](https://github.com/Aidoku/Aidoku) 17 | - Paperback ~(not a fan, but great work nevertheless)~ 18 | - One from the app store (no seriously don't do this they are mostly full of ads) 19 | 20 | ## Features 21 | - MultiSource (see progress in [MangaScraper](https://github.com/AzSiAz/MangaScraper) repository) 22 | - Library stored in GRDB 23 | - Multi collection (only one manga per collection) 24 | - some Library Filter and order (saved) 25 | - Very basic manga/webtoon/etc reader and a way to change reader direction 26 | - Backup/Restore 27 | 28 | ## Working On 29 | - Save reader direction for manga/webtoon/etc. 30 | 31 | ## Planned 32 | - Tracker (maybe, I don't use them anymore) 33 | - Scroll to next chapter in reader 34 | - One manga in multiple collection, maybe 35 | - Cleanup DB, cache 36 | - Cloudflare bypass (pain in the ass when you are hit currently) 37 | - Double Page reader 38 | - Download 39 | - CloudKit 40 | --------------------------------------------------------------------------------