├── 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 |
--------------------------------------------------------------------------------